feat(daemon): preserve URL across version-mismatch restart
Before: after `npm i -g` upgrade, the next agent-browser command would detect daemon version mismatch, kill the old daemon, spawn a fresh one, and connect to a brand-new about:blank tab. The user's previous navigation state was silently lost — `get url` returned about:blank even though the user's Chrome was still on the same page. Now: before killing the old daemon, the CLI synchronously asks it for its current URL via the existing socket. If non-empty and not about:blank, it's persisted to a `.restore-url` sidecar in the socket dir. After the new daemon spawns and auto-connects, it reads the sidecar (read-and-delete), navigates the fresh tab to the saved URL, and prints `⚠ Restored previous URL: <url>`. Manual `agent-browser close` does NOT write the sidecar, so a clean shutdown won't trigger surprise navigation. The sidecar is consumed on read regardless of whether navigation succeeded, so a stale entry can't haunt later auto-launches.
This commit is contained in:
@@ -127,6 +127,15 @@ fn get_version_path(session: &str) -> PathBuf {
|
|||||||
get_socket_dir().join(format!("{}.version", session))
|
get_socket_dir().join(format!("{}.version", session))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path to the sidecar file that records the URL the previous daemon was on,
|
||||||
|
/// used to restore navigation after a version-mismatch restart. Only written
|
||||||
|
/// when the version-mismatch branch fires; cleared after the new daemon
|
||||||
|
/// reads it. Manual `close` does not write this file, so a clean shutdown
|
||||||
|
/// won't trigger surprise navigation.
|
||||||
|
pub fn get_restore_url_path(session: &str) -> PathBuf {
|
||||||
|
get_socket_dir().join(format!("{}.restore-url", session))
|
||||||
|
}
|
||||||
|
|
||||||
/// Clean up stale socket and PID files for a session
|
/// Clean up stale socket and PID files for a session
|
||||||
pub fn cleanup_stale_files(session: &str) {
|
pub fn cleanup_stale_files(session: &str) {
|
||||||
let pid_path = get_pid_path(session);
|
let pid_path = get_pid_path(session);
|
||||||
@@ -135,6 +144,10 @@ pub fn cleanup_stale_files(session: &str) {
|
|||||||
let _ = fs::remove_file(&version_path);
|
let _ = fs::remove_file(&version_path);
|
||||||
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
||||||
let _ = fs::remove_file(&stream_path);
|
let _ = fs::remove_file(&stream_path);
|
||||||
|
// Note: the .restore-url sidecar is intentionally NOT removed here —
|
||||||
|
// it lives across the brief window between killing the old daemon
|
||||||
|
// and the new daemon reading it back. The new daemon deletes it after
|
||||||
|
// restoring (see actions::auto_launch).
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -527,6 +540,24 @@ fn daemon_version_matches(session: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-shot socket query for the running daemon's current URL.
|
||||||
|
/// Returns None on any kind of failure — caller must treat as best-effort.
|
||||||
|
fn query_current_url(session: &str) -> Option<String> {
|
||||||
|
let cmd = serde_json::json!({
|
||||||
|
"id": format!("restore-url-probe-{}", std::process::id()),
|
||||||
|
"action": "url",
|
||||||
|
});
|
||||||
|
let resp = send_command_once(&cmd, session).ok()?;
|
||||||
|
if !resp.success {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
resp.data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("url"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Kill a running daemon by reading its PID file and sending a kill signal.
|
/// Kill a running daemon by reading its PID file and sending a kill signal.
|
||||||
fn kill_stale_daemon(session: &str) {
|
fn kill_stale_daemon(session: &str) {
|
||||||
// Remove the socket first so no new connections reach the old daemon
|
// Remove the socket first so no new connections reach the old daemon
|
||||||
@@ -592,6 +623,16 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
|||||||
"{} Daemon version mismatch detected, restarting...",
|
"{} Daemon version mismatch detected, restarting...",
|
||||||
crate::color::warning_indicator()
|
crate::color::warning_indicator()
|
||||||
);
|
);
|
||||||
|
// Best-effort: ask the old daemon for its current URL so the
|
||||||
|
// new daemon can restore navigation after auto-connect. If the
|
||||||
|
// query fails (already shutting down, no browser, etc.) we
|
||||||
|
// silently skip — the user just sees about:blank as before.
|
||||||
|
if let Some(url) = query_current_url(session) {
|
||||||
|
if !url.is_empty() && url != "about:blank" {
|
||||||
|
let path = get_restore_url_path(session);
|
||||||
|
let _ = fs::write(&path, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
kill_stale_daemon(session);
|
kill_stale_daemon(session);
|
||||||
// Fall through to spawn a new daemon below
|
// Fall through to spawn a new daemon below
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
|||||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||||
use tokio::sync::{broadcast, oneshot, RwLock};
|
use tokio::sync::{broadcast, oneshot, RwLock};
|
||||||
|
|
||||||
use crate::connection::get_socket_dir;
|
use crate::connection::{get_restore_url_path, get_socket_dir};
|
||||||
|
|
||||||
use super::auth;
|
use super::auth;
|
||||||
use super::browser::{should_track_target, BrowserManager, WaitUntil};
|
use super::browser::{should_track_target, BrowserManager, WaitUntil};
|
||||||
@@ -1552,6 +1552,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
try_auto_restore_state(state).await;
|
try_auto_restore_state(state).await;
|
||||||
try_load_storage_state(state, &storage_state_path).await;
|
try_load_storage_state(state, &storage_state_path).await;
|
||||||
apply_stealth_to_browser(state).await;
|
apply_stealth_to_browser(state).await;
|
||||||
|
try_restore_navigation(state).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,6 +1574,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
try_auto_restore_state(state).await;
|
try_auto_restore_state(state).await;
|
||||||
try_load_storage_state(state, &storage_state_path).await;
|
try_load_storage_state(state, &storage_state_path).await;
|
||||||
apply_stealth_to_browser(state).await;
|
apply_stealth_to_browser(state).await;
|
||||||
|
try_restore_navigation(state).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
@@ -1660,6 +1662,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
|||||||
try_load_storage_state(state, &storage_state_path).await;
|
try_load_storage_state(state, &storage_state_path).await;
|
||||||
// Apply stealth anti-detection patches after browser is ready
|
// Apply stealth anti-detection patches after browser is ready
|
||||||
apply_stealth_to_browser(state).await;
|
apply_stealth_to_browser(state).await;
|
||||||
|
try_restore_navigation(state).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1770,6 +1773,47 @@ async fn apply_stealth_to_browser(state: &DaemonState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
||||||
|
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
||||||
|
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
|
||||||
|
/// the page the user was on. Read-and-delete: the file is removed regardless
|
||||||
|
/// of whether navigation succeeds, so a stale sidecar can't haunt later
|
||||||
|
/// auto-launches.
|
||||||
|
async fn try_restore_navigation(state: &mut DaemonState) {
|
||||||
|
let path = get_restore_url_path(&state.session_id);
|
||||||
|
let url = match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => s.trim().to_string(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
if url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(mgr) = state.browser.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.ref_map.clear();
|
||||||
|
state.iframe_sessions.clear();
|
||||||
|
state.active_frame_id = None;
|
||||||
|
match mgr.navigate(&url, super::browser::WaitUntil::Load).await {
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Restored previous URL: {}",
|
||||||
|
crate::color::warning_indicator(),
|
||||||
|
url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Could not restore previous URL ({}): {}",
|
||||||
|
crate::color::warning_indicator(),
|
||||||
|
url,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn launch_options_from_env() -> LaunchOptions {
|
fn launch_options_from_env() -> LaunchOptions {
|
||||||
let headed = env::var("AGENT_BROWSER_HEADED")
|
let headed = env::var("AGENT_BROWSER_HEADED")
|
||||||
.map(|v| v == "1" || v == "true")
|
.map(|v| v == "1" || v == "true")
|
||||||
|
|||||||
Reference in New Issue
Block a user