use serde_json::Value; use std::env; use std::fs; use std::io::Write; use std::path::PathBuf; use std::process; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::signal; use tokio::sync::{mpsc, Notify, RwLock}; use super::actions::{execute_command, DaemonState}; use super::cdp::client::CdpClient; use super::state; use super::stream::StreamServer; pub async fn run_daemon(session: &str) { // Record this daemon's session so tabs it opens on the shared real Chrome // (via the ab-connect extension) land in a per-session Chrome tab group. let _ = super::browser::DAEMON_SESSION.set(session.to_string()); let socket_dir = get_daemon_socket_dir(); if !socket_dir.exists() { let _ = fs::create_dir_all(&socket_dir); } // When debug mode is on, redirect stderr to a log file so daemon // output can be inspected (the daemon normally has stderr piped to its // parent which drops the read end after startup). #[cfg(unix)] if env::var("AGENT_BROWSER_DEBUG").is_ok() { let log_path = socket_dir.join(format!("{}.log", session)); if let Ok(file) = fs::File::create(&log_path) { use std::os::unix::io::IntoRawFd; let fd = file.into_raw_fd(); unsafe { libc::dup2(fd, 2); libc::close(fd); } let _ = writeln!( std::io::stderr(), "[daemon] Debug logging started for session: {}", session ); } } else { // Redirect stderr to /dev/null to prevent daemon crash when the // parent CLI drops the piped stderr handle after startup. Cloud // providers (AgentCore, Browserbase, etc.) may write to stderr // during connection setup; a broken pipe would kill the daemon. #[cfg(unix)] { use std::os::unix::io::IntoRawFd; if let Ok(devnull) = fs::File::create("/dev/null") { let fd = devnull.into_raw_fd(); unsafe { libc::dup2(fd, 2); libc::close(fd); } } } } // Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't // run on kill -9). Only removes dirs no live process references. super::cdp::chrome::cleanup_orphaned_chrome_profiles(); let pid_path = socket_dir.join(format!("{}.pid", session)); let _ = fs::write(&pid_path, process::id().to_string()); let version_path = socket_dir.join(format!("{}.version", session)); let _ = fs::write(&version_path, env!("CARGO_PKG_VERSION")); // On Unix the daemon listens on a Unix domain socket; on Windows it uses // TCP, so there is no .sock file — only a .port file written by the server. let socket_path = socket_dir.join(format!("{}.sock", session)); #[cfg(unix)] if socket_path.exists() { let _ = fs::remove_file(&socket_path); } #[cfg(windows)] { let _ = fs::remove_file(socket_dir.join(format!("{}.port", session))); } let stream_path = socket_dir.join(format!("{}.stream", session)); let _ = fs::remove_file(&stream_path); let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session))); if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") { if let Ok(days) = days_str.parse::() { if days > 0 { let _ = state::state_clean(days); } } } let mut stream_client: Option>>>> = None; let mut stream_server_instance: Option> = None; let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(0); match StreamServer::start_without_client(preferred_port, session.to_string(), true).await { Ok((stream_server, client_slot)) => { stream_client = Some(client_slot.clone()); if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) { let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e); } stream_server_instance = Some(Arc::new(stream_server)); } Err(e) => { let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e); } } // Auto-shutdown the daemon after this many ms of inactivity (no commands received). // Disabled when unset or 0. let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") .ok() .and_then(|s| s.parse::().ok()) .filter(|&ms| ms > 0); let result = run_socket_server( &socket_path, session, stream_client, stream_server_instance, idle_timeout_ms, ) .await; #[cfg(unix)] { let _ = fs::remove_file(&socket_path); } #[cfg(windows)] { let _ = fs::remove_file(socket_dir.join(format!("{}.port", session))); } let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(&version_path); let _ = fs::remove_file(&stream_path); let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session))); let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session))); if let Err(e) = result { let _ = writeln!(std::io::stderr(), "Daemon error: {}", e); process::exit(1); } } #[cfg(unix)] async fn run_socket_server( socket_path: &PathBuf, session: &str, stream_client: Option>>>>, stream_server: Option>, idle_timeout_ms: Option, ) -> Result<(), String> { use tokio::net::UnixListener; let listener = UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?; let stream_file: Option = if stream_server.is_some() { let dir = socket_path.parent().unwrap_or(std::path::Path::new(".")); Some(dir.join(format!("{}.stream", session))) } else { None }; let state: std::sync::Arc> = std::sync::Arc::new( tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)), ); let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64); let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx)); // Notifier used by handle_connection to signal the daemon loop to exit // after a "close" command, instead of calling process::exit() which skips // destructors and can leave Chrome processes orphaned (issue #1113). let close_notify = Arc::new(Notify::new()); let mut drain_interval = tokio::time::interval(Duration::from_millis(100)); drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let idle_sleep = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms))); let mut idle_sleep_pin = idle_sleep.map(Box::pin); loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, _)) => { let state = state.clone(); let reset_tx = reset_tx.clone(); let sf = stream_file.clone(); let cn = close_notify.clone(); tokio::spawn(async move { handle_connection(stream, state, reset_tx, sf, cn).await; }); } Err(e) => { let _ = writeln!(std::io::stderr(), "Accept error: {}", e); } } } _ = drain_interval.tick() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { if mgr.has_process_exited() { let _ = mgr.close().await; s.browser = None; s.screencasting = false; s.update_stream_client().await; } else { s.drain_cdp_events_background().await; } } } _ = async { match idle_sleep_pin { Some(ref mut s) => s.as_mut().await, None => std::future::pending::<()>().await, } }, if idle_timeout_ms.is_some() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { let _ = mgr.close().await; } break; } _ = reset_rx.recv(), if idle_timeout_ms.is_some() => { idle_sleep_pin = idle_timeout_ms .map(|ms| Box::pin(tokio::time::sleep(Duration::from_millis(ms)))); continue; } _ = close_notify.notified() => { // "close" command was handled; browser already closed by // handle_close(). Break to run cleanup and exit gracefully // so destructors fire. break; } _ = shutdown_signal() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { let _ = mgr.close().await; } break; } } } Ok(()) } #[cfg(windows)] async fn run_socket_server( socket_path: &PathBuf, session: &str, stream_client: Option>>>>, stream_server: Option>, idle_timeout_ms: Option, ) -> Result<(), String> { use tokio::net::TcpListener; let preferred_port = get_port_for_session(session); // Try the hash-derived port first; if it is blocked (e.g. Windows Hyper-V // excluded port range), fall back to an OS-assigned ephemeral port. let listener = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await { Ok(l) => l, Err(_) => TcpListener::bind("127.0.0.1:0") .await .map_err(|e| format!("Failed to bind TCP: {}", e))?, }; let actual_port = listener .local_addr() .map_err(|e| format!("Failed to get local address: {}", e))? .port(); let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new(".")); let port_path = socket_dir.join(format!("{}.port", session)); let _ = fs::write(&port_path, actual_port.to_string()); let stream_file: Option = if stream_server.is_some() { Some(socket_dir.join(format!("{}.stream", session))) } else { None }; let state: std::sync::Arc> = std::sync::Arc::new( tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)), ); let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64); let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx)); let close_notify = Arc::new(Notify::new()); let idle_sleep = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms))); let mut idle_sleep_pin = idle_sleep.map(Box::pin); loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok((stream, _)) => { let state = state.clone(); let reset_tx = reset_tx.clone(); let sf = stream_file.clone(); let cn = close_notify.clone(); tokio::spawn(async move { handle_connection(stream, state, reset_tx, sf, cn).await; }); } Err(e) => { let _ = writeln!(std::io::stderr(), "Accept error: {}", e); } } } _ = async { match idle_sleep_pin { Some(ref mut s) => s.as_mut().await, None => std::future::pending::<()>().await, } }, if idle_timeout_ms.is_some() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { let _ = mgr.close().await; } let _ = fs::remove_file(&port_path); break; } _ = reset_rx.recv(), if idle_timeout_ms.is_some() => { idle_sleep_pin = idle_timeout_ms .map(|ms| Box::pin(tokio::time::sleep(Duration::from_millis(ms)))); continue; } _ = close_notify.notified() => { let _ = fs::remove_file(&port_path); break; } _ = shutdown_signal() => { let mut s = state.lock().await; if let Some(ref mut mgr) = s.browser { let _ = mgr.close().await; } let _ = fs::remove_file(&port_path); break; } } } Ok(()) } async fn handle_connection( stream: S, state: std::sync::Arc>, idle_reset_tx: Option>>, stream_file_cleanup: Option, close_notify: Arc, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { let (reader, mut writer) = tokio::io::split(stream); let mut buf_reader = BufReader::new(reader); let mut line = String::new(); loop { line.clear(); match buf_reader.read_line(&mut line).await { Ok(0) => break, Ok(_) => { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if looks_like_http(trimmed) { break; } let cmd: Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { let err = serde_json::json!({ "success": false, "error": format!("Invalid JSON: {}", e), }); let mut resp = serde_json::to_string(&err).unwrap_or_default(); resp.push('\n'); let _ = writer.write_all(resp.as_bytes()).await; continue; } }; if let Some(ref tx) = idle_reset_tx { let _ = tx.try_send(()); } let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close"); let response = { let mut s = state.lock().await; execute_command(&cmd, &mut s).await }; let mut resp = serde_json::to_string(&response).unwrap_or_default(); resp.push('\n'); if writer.write_all(resp.as_bytes()).await.is_err() { break; } if is_close { if let Some(ref path) = stream_file_cleanup { let _ = fs::remove_file(path); } // Signal the daemon loop to exit gracefully instead of // calling process::exit(), which skips destructors and // can leave Chrome processes orphaned (issue #1113). tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; close_notify.notify_one(); return; } } Err(_) => break, } } } fn looks_like_http(line: &str) -> bool { let prefixes = [ "GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ", ]; prefixes.iter().any(|p| line.starts_with(p)) } async fn shutdown_signal() { #[cfg(unix)] { let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) { Ok(s) => s, Err(e) => { let _ = writeln!(std::io::stderr(), "Failed to install SIGINT handler: {}", e); process::exit(1); } }; let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) { Ok(s) => s, Err(e) => { let _ = writeln!( std::io::stderr(), "Failed to install SIGTERM handler: {}", e ); process::exit(1); } }; let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) { Ok(s) => s, Err(e) => { let _ = writeln!(std::io::stderr(), "Failed to install SIGHUP handler: {}", e); process::exit(1); } }; tokio::select! { _ = sigint.recv() => {} _ = sigterm.recv() => {} _ = sighup.recv() => {} } } #[cfg(windows)] { if let Err(e) = signal::ctrl_c().await { let _ = writeln!(std::io::stderr(), "Failed to install Ctrl+C handler: {}", e); process::exit(1); } } } fn get_daemon_socket_dir() -> PathBuf { if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") { if !dir.is_empty() { return PathBuf::from(dir); } } if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") { if !xdg.is_empty() { return PathBuf::from(xdg).join("chrome-use"); } } if let Some(home) = dirs::home_dir() { return home.join(".chrome-use"); } std::env::temp_dir().join("chrome-use") } #[cfg(windows)] fn get_port_for_session(session: &str) -> u16 { let mut hash: i32 = 0; for c in session.chars() { hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32); } 49152 + ((hash.unsigned_abs() as u32 % 16383) as u16) } #[cfg(test)] mod tests { #[allow(unused_imports)] use super::*; #[cfg(windows)] #[test] fn test_port_matches_client_algorithm() { assert_eq!(get_port_for_session("default"), 50838); assert_eq!(get_port_for_session("my-session"), 63105); assert_eq!(get_port_for_session("work"), 51184); assert_eq!(get_port_for_session(""), 49152); } /// Guard against re-introducing `waitpid(-1)` in daemon code. /// /// Issue #1035: a SIGCHLD handler that called `waitpid(-1, WNOHANG)` was /// added in v0.22.3 to reap zombie Chrome processes. This races with /// Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)` /// reaps *any* child, stealing the exit status before Rust can collect /// it. The result is ECHILD errors in `BrowserManager::has_process_exited()` /// and `ChromeProcess::kill()`, which can leave the daemon in a broken /// state or cause hangs on certain Linux configurations. /// /// The fix uses the existing 500ms drain interval to call /// `has_process_exited()` (which delegates to `Child::try_wait()`) /// for targeted, race-free zombie detection. #[test] fn test_no_waitpid_minus_one_in_daemon() { let source = include_str!("daemon.rs"); // Only check production code (everything before `#[cfg(test)]`) let production_code = source.split("#[cfg(test)]").next().unwrap_or(source); assert!( !production_code.contains("waitpid(-1"), "daemon.rs production code must not call waitpid(-1, ...). \ Use Child::try_wait() via has_process_exited() instead. \ See issue #1035." ); } /// Verify that `Child::try_wait()` correctly detects a crashed child /// without needing a global SIGCHLD handler or `waitpid(-1)`. /// This is what `has_process_exited()` uses in the fixed code. #[cfg(unix)] #[test] fn test_child_try_wait_detects_exit_without_sigchld_handler() { use std::process::{Command, Stdio}; let mut child = Command::new("/bin/sh") .args(["-c", "exit 42"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("failed to spawn child"); std::thread::sleep(std::time::Duration::from_millis(200)); match child.try_wait() { Ok(Some(status)) => { assert!( !status.success(), "child exited with code 42, should not be success" ); } Ok(None) => panic!("try_wait() returned None but child should have exited"), Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e), } } /// Regression test for #1101: idle timeout must fire even while the /// drain interval ticks every 500 ms. The bug was that `sleep_future` /// was created **inside** the loop, so each drain tick dropped the /// in-progress sleep and replaced it with a fresh one – the timer /// could never reach its deadline. #[tokio::test] async fn test_idle_timeout_fires_despite_drain_interval() { use tokio::sync::mpsc; let idle_timeout_ms: u64 = 1000; let mut drain_interval = tokio::time::interval(Duration::from_millis(500)); drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let (_reset_tx, mut reset_rx) = mpsc::channel::<()>(64); let start = tokio::time::Instant::now(); let exited = tokio::time::timeout(Duration::from_secs(5), async { let mut idle_sleep_pin = Some(Box::pin(tokio::time::sleep(Duration::from_millis( idle_timeout_ms, )))); loop { tokio::select! { _ = drain_interval.tick() => {} _ = async { match idle_sleep_pin { Some(ref mut s) => s.as_mut().await, None => std::future::pending::<()>().await, } } => { break; } _ = reset_rx.recv() => { idle_sleep_pin = Some(Box::pin( tokio::time::sleep(Duration::from_millis(idle_timeout_ms)), )); continue; } } } }) .await; let elapsed = start.elapsed(); assert!( exited.is_ok(), "idle timeout never fired – loop ran for >5 s (bug #1101)" ); assert!( elapsed < Duration::from_millis(idle_timeout_ms + 500), "idle timeout took too long: {:?} (expected ~{} ms)", elapsed, idle_timeout_ms, ); } /// Verify that `ChromeProcess::has_exited()` (which uses `Child::try_wait()`) /// correctly detects a killed child, the same way the drain interval does /// in the fixed daemon code. This ensures crash detection works without /// a SIGCHLD handler. #[cfg(unix)] #[test] fn test_has_exited_detects_killed_process() { use std::process::{Command, Stdio}; let mut child = Command::new("/bin/sh") .args(["-c", "sleep 60"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("failed to spawn child"); // Process should be running match child.try_wait() { Ok(None) => {} // expected other => panic!("expected Ok(None) for running process, got {:?}", other), } // Kill it (simulates Chrome crash) child.kill().expect("failed to kill child"); std::thread::sleep(std::time::Duration::from_millis(100)); // try_wait should detect the exit match child.try_wait() { Ok(Some(_)) => {} // expected: detected the crash other => panic!( "expected Ok(Some(_)) after kill, got {:?}. \ Crash detection via try_wait() must work for the drain \ interval fix (issue #1035) to function correctly.", other ), } } }