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::{Notify, RwLock};
use super::actions::{
auto_save_restore_state, close_all_browser_backends, close_current_browser, execute_command,
maybe_autosave_restore_state, DaemonState,
};
use super::cdp::client::CdpClient;
use super::state;
use super::stream::{IdleActivity, StreamServer};
use crate::connection::INTERNAL_DAEMON_SHUTDOWN_ACTION;
use crate::session_files::{owns_session, remove_daemon_files, SessionFile};
use crate::session_settings::{FsStore, Running, SecurityView, Store, DEFAULT_IDLE_TIMEOUT_MS};
use crate::validation::{is_valid_session_name, session_name_error};
pub(super) struct Startup {
pub settings: Running,
pub store: Arc<dyn Store>,
}
pub async fn run_daemon(session: &str) {
if !is_valid_session_name(session) {
eprintln!("{}", session_name_error(session));
process::exit(1);
}
let socket_dir = get_daemon_socket_dir();
if !socket_dir.exists() {
let _ = fs::create_dir_all(&socket_dir);
}
let store: Arc<dyn Store> = Arc::new(FsStore::new(&socket_dir, session));
let settings = match Running::boot(store.as_ref(), session) {
Ok(settings) => settings,
Err(e) => {
eprintln!("{}", e);
process::exit(1);
}
};
#[cfg(unix)]
if env::var("AGENT_BROWSER_DEBUG").is_ok() {
let log_path = SessionFile::Log.path_in(&socket_dir, 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 {
#[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);
}
}
}
}
let pid_path = SessionFile::Pid.path_in(&socket_dir, session);
let _ = fs::write(&pid_path, process::id().to_string());
let version_path = SessionFile::Version.path_in(&socket_dir, session);
let _ = fs::write(&version_path, env!("CARGO_PKG_VERSION"));
let socket_path = SessionFile::Socket.path_in(&socket_dir, session);
#[cfg(unix)]
if socket_path.exists() {
let _ = fs::remove_file(&socket_path);
}
#[cfg(windows)]
{
let _ = fs::remove_file(SessionFile::Port.path_in(&socket_dir, session));
}
let stream_path = SessionFile::Stream.path_in(&socket_dir, session);
let _ = fs::remove_file(&stream_path);
let _ = fs::remove_file(SessionFile::Engine.path_in(&socket_dir, session));
let _ = fs::remove_file(SessionFile::Provider.path_in(&socket_dir, session));
let _ = fs::remove_file(SessionFile::Extensions.path_in(&socket_dir, session));
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
if let Ok(days) = days_str.parse::<u64>() {
if days > 0 {
let _ = state::state_clean(days);
}
}
}
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
let idle_activity = Arc::new(IdleActivity::new());
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
match StreamServer::start_without_client(
preferred_port,
session.to_string(),
true,
idle_activity.clone(),
)
.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);
}
}
let autosave_interval_ms = autosave_interval_ms_from_env();
let result = run_socket_server(
&socket_path,
session,
stream_client,
stream_server_instance,
idle_activity,
autosave_interval_ms,
Startup { settings, store },
)
.await;
if owns_session(&socket_dir, session, process::id()) {
remove_daemon_files(&socket_dir, session);
}
if let Err(e) = result {
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
process::exit(1);
}
}
fn repair_stored_security(store: &dyn Store, security: &SecurityView) {
if *security != SecurityView::default() {
let _ = store.save(security);
}
}
fn remaining_idle_timeout(activity: &IdleActivity, timeout_ms: u64) -> Option<Duration> {
Duration::from_millis(timeout_ms).checked_sub(activity.elapsed())
}
fn arm_idle_sleep(activity: &IdleActivity) -> Option<std::pin::Pin<Box<tokio::time::Sleep>>> {
let timeout = activity.timeout()?;
let remaining = remaining_idle_timeout(activity, timeout.ms).unwrap_or_default();
Some(Box::pin(tokio::time::sleep(remaining)))
}
fn autosave_interval_ms_from_env() -> u64 {
env::var("AGENT_BROWSER_AUTOSAVE_INTERVAL_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(30_000)
}
#[cfg(unix)]
async fn run_socket_server(
socket_path: &PathBuf,
_session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
stream_server: Option<Arc<StreamServer>>,
idle_activity: Arc<IdleActivity>,
autosave_interval_ms: u64,
startup: Startup,
) -> Result<(), String> {
use tokio::net::UnixListener;
let idle_timeout = startup.settings.idle_timeout();
let security = startup.settings.security();
let store = startup.store.clone();
idle_activity.set_timeout(idle_timeout);
let idle_timeout_ms = idle_timeout.map(|t| t.ms);
let listener =
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
repair_stored_security(store.as_ref(), &security);
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new_with_stream(
startup.settings,
startup.store,
stream_client,
stream_server,
idle_activity.clone(),
)));
let close = Arc::new(CloseHandshake::default());
let mut closed_by_command = false;
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);
let mut idle_lock: Option<IdleLock> = None;
loop {
let idle_timeout = idle_activity.timeout();
let idle_timeout_ms = idle_timeout.map(|t| t.ms);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let idle_activity = idle_activity.clone();
let close = close.clone();
tokio::spawn(async move {
handle_connection(stream, state, idle_activity, close).await;
});
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
}
}
}
_ = drain_interval.tick() => {
spawn_background_tick_if_idle(state.clone(), autosave_interval_ms);
}
_ = async {
match idle_sleep_pin {
Some(ref mut s) => s.as_mut().await,
None => std::future::pending::<()>().await,
}
}, if idle_timeout_ms.is_some() && idle_lock.is_none() => {
idle_lock = Some(Box::pin(state.clone().lock_owned()));
}
s = async { idle_lock.as_mut().expect("idle lock armed").await }, if idle_lock.is_some() => {
idle_lock = None;
let mut s = s;
let idle_timeout = idle_activity.timeout();
let Some(timeout_ms) = idle_timeout.map(|t| t.ms) else {
idle_sleep_pin = None;
continue;
};
if let Some(remaining) =
remaining_idle_timeout(&idle_activity, timeout_ms)
{
idle_sleep_pin = Some(Box::pin(tokio::time::sleep(remaining)));
continue;
}
if idle_timeout.is_some_and(|t| t.is_default)
&& s.blocks_default_idle_shutdown()
{
idle_sleep_pin =
Some(Box::pin(tokio::time::sleep(Duration::from_millis(timeout_ms))));
continue;
}
if idle_timeout.is_some_and(|t| t.is_default) {
let _ = writeln!(
std::io::stderr(),
"Idle for {}m with no commands or dashboard input; saving configured restore state and shutting down (AGENT_BROWSER_IDLE_TIMEOUT_MS=0 disables)",
DEFAULT_IDLE_TIMEOUT_MS / 60_000
);
}
let _ = auto_save_restore_state(&mut s).await;
let _ = close_all_browser_backends(&mut s).await;
break;
}
_ = idle_activity.notified() => {
idle_sleep_pin = arm_idle_sleep(&idle_activity);
continue;
}
_ = close.requested.notified() => {
closed_by_command = true;
break;
}
_ = shutdown_signal() => {
let mut s = match idle_lock.take() {
Some(idle_lock) => idle_lock.await,
None => state.clone().lock_owned().await,
};
let _ = auto_save_restore_state(&mut s).await;
let _ = close_all_browser_backends(&mut s).await;
break;
}
}
}
if closed_by_command {
close.finish(listener).await;
}
Ok(())
}
#[cfg(windows)]
async fn run_socket_server(
socket_path: &PathBuf,
session: &str,
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
stream_server: Option<Arc<StreamServer>>,
idle_activity: Arc<IdleActivity>,
autosave_interval_ms: u64,
startup: Startup,
) -> Result<(), String> {
use tokio::net::TcpListener;
let idle_timeout = startup.settings.idle_timeout();
let security = startup.settings.security();
let store = startup.store.clone();
idle_activity.set_timeout(idle_timeout);
let idle_timeout_ms = idle_timeout.map(|t| t.ms);
let preferred_port = get_port_for_session(session);
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 = SessionFile::Port.path_in(socket_dir, session);
let _ = fs::write(&port_path, actual_port.to_string());
repair_stored_security(store.as_ref(), &security);
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> =
std::sync::Arc::new(tokio::sync::Mutex::new(DaemonState::new_with_stream(
startup.settings,
startup.store,
stream_client,
stream_server,
idle_activity.clone(),
)));
let close = Arc::new(CloseHandshake::default());
let mut closed_by_command = false;
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);
let mut idle_lock: Option<IdleLock> = None;
let mut drain_interval = tokio::time::interval(Duration::from_millis(100));
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
let idle_timeout = idle_activity.timeout();
let idle_timeout_ms = idle_timeout.map(|t| t.ms);
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _)) => {
let state = state.clone();
let idle_activity = idle_activity.clone();
let close = close.clone();
tokio::spawn(async move {
handle_connection(stream, state, idle_activity, close).await;
});
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
}
}
}
_ = drain_interval.tick() => {
spawn_background_tick_if_idle(state.clone(), autosave_interval_ms);
}
_ = async {
match idle_sleep_pin {
Some(ref mut s) => s.as_mut().await,
None => std::future::pending::<()>().await,
}
}, if idle_timeout_ms.is_some() && idle_lock.is_none() => {
idle_lock = Some(Box::pin(state.clone().lock_owned()));
}
s = async { idle_lock.as_mut().expect("idle lock armed").await }, if idle_lock.is_some() => {
idle_lock = None;
let mut s = s;
let idle_timeout = idle_activity.timeout();
let Some(timeout_ms) = idle_timeout.map(|t| t.ms) else {
idle_sleep_pin = None;
continue;
};
if let Some(remaining) =
remaining_idle_timeout(&idle_activity, timeout_ms)
{
idle_sleep_pin = Some(Box::pin(tokio::time::sleep(remaining)));
continue;
}
if idle_timeout.is_some_and(|t| t.is_default)
&& s.blocks_default_idle_shutdown()
{
idle_sleep_pin =
Some(Box::pin(tokio::time::sleep(Duration::from_millis(timeout_ms))));
continue;
}
if idle_timeout.is_some_and(|t| t.is_default) {
let _ = writeln!(
std::io::stderr(),
"Idle for {}m with no commands or dashboard input; saving configured restore state and shutting down (AGENT_BROWSER_IDLE_TIMEOUT_MS=0 disables)",
DEFAULT_IDLE_TIMEOUT_MS / 60_000
);
}
let _ = auto_save_restore_state(&mut s).await;
let _ = close_all_browser_backends(&mut s).await;
break;
}
_ = idle_activity.notified() => {
idle_sleep_pin = arm_idle_sleep(&idle_activity);
continue;
}
_ = close.requested.notified() => {
closed_by_command = true;
break;
}
_ = shutdown_signal() => {
let mut s = match idle_lock.take() {
Some(idle_lock) => idle_lock.await,
None => state.clone().lock_owned().await,
};
let _ = auto_save_restore_state(&mut s).await;
let _ = close_all_browser_backends(&mut s).await;
break;
}
}
}
if closed_by_command {
close.finish(listener).await;
}
Ok(())
}
type IdleLock = std::pin::Pin<
Box<dyn std::future::Future<Output = tokio::sync::OwnedMutexGuard<DaemonState>> + Send>,
>;
fn spawn_background_tick_if_idle(
state: Arc<tokio::sync::Mutex<DaemonState>>,
autosave_interval_ms: u64,
) -> bool {
let Ok(mut state) = state.try_lock_owned() else {
return false;
};
tokio::spawn(async move {
let process_exited = state
.browser
.as_mut()
.map(|mgr| mgr.has_process_exited())
.unwrap_or(false);
if process_exited {
let _ = close_current_browser(&mut state).await;
} else if state.browser.is_some() {
if let Err(error) = state.drain_cdp_events_background().await {
let _ = writeln!(
std::io::stderr(),
"Failed to apply browser network controls: {}",
error
);
} else {
maybe_autosave_restore_state(&mut state, autosave_interval_ms).await;
}
}
});
true
}
#[derive(Default)]
struct CloseHandshake {
requested: Notify,
listener_closed: Notify,
replied: Notify,
}
impl CloseHandshake {
async fn finish<L>(&self, listener: L) {
drop(listener);
self.listener_closed.notify_one();
let _ = tokio::time::timeout(Duration::from_secs(1), self.replied.notified()).await;
}
}
async fn handle_connection<S>(
stream: S,
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
idle_activity: Arc<IdleActivity>,
close: Arc<CloseHandshake>,
) 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;
}
};
idle_activity.mark();
let action = cmd
.get("action")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let response = {
let mut s = state.lock().await;
let response = execute_command(&cmd, &mut s).await;
idle_activity.mark();
response
};
let closing = close_completed_response(&action, &response);
if closing {
close.requested.notify_one();
close.listener_closed.notified().await;
}
if response.get("code").and_then(Value::as_str) == Some("page_wedged") {
if let Some(id) = response.get("id").and_then(Value::as_str) {
state.lock().await.wedge_report_delivered(id);
}
}
let mut resp = serde_json::to_string(&response).unwrap_or_default();
resp.push('\n');
let written = writer.write_all(resp.as_bytes()).await.is_ok();
if closing {
let _ = writer.flush().await;
close.replied.notify_one();
return;
}
if !written {
break;
}
}
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))
}
fn close_completed_response(action: &str, response: &Value) -> bool {
if !matches!(
action,
"close" | "confirm" | INTERNAL_DAEMON_SHUTDOWN_ACTION
) {
return false;
}
fn data_closed(data: &Value) -> bool {
data.get("closed").and_then(|v| v.as_bool()) == Some(true)
}
if response.get("success").and_then(|v| v.as_bool()) != Some(true) {
return false;
}
let Some(data) = response.get("data") else {
return false;
};
if data_closed(data) {
return true;
}
data.get("result").is_some_and(|result| {
result.get("success").and_then(|v| v.as_bool()) == Some(true)
&& result.get("data").is_some_and(data_closed)
})
}
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 {
crate::connection::get_socket_dir()
}
#[cfg(windows)]
fn get_port_for_session(session: &str) -> u16 {
crate::connection::get_port_for_session(session)
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
use crate::session_settings::{resolve_idle_timeout, IdleTimeout, MemStore};
#[test]
fn test_a_ready_daemon_restores_settings_a_sweep_removed() {
let store = MemStore::default();
let security = SecurityView {
allowed_domains: Some(vec!["example.com".into()]),
..SecurityView::default()
};
assert_eq!(store.stored(), None);
repair_stored_security(&store, &security);
assert_eq!(store.stored(), Some(security));
}
#[test]
fn test_a_ready_daemon_without_security_settings_stores_nothing() {
let store = MemStore::default();
repair_stored_security(&store, &SecurityView::default());
assert_eq!(store.stored(), None);
}
#[test]
fn test_resolve_idle_timeout_unset_applies_default() {
let t = resolve_idle_timeout(None).expect("default should apply when unset");
assert_eq!(t.ms, DEFAULT_IDLE_TIMEOUT_MS);
assert!(t.is_default);
}
#[test]
fn test_resolve_idle_timeout_explicit_zero_disables() {
assert!(resolve_idle_timeout(Some("0".to_string())).is_none());
assert!(resolve_idle_timeout(Some(" 0 ".to_string())).is_none());
}
#[test]
fn test_resolve_idle_timeout_explicit_value_is_not_default() {
let t = resolve_idle_timeout(Some("5000".to_string())).expect("explicit value");
assert_eq!(t.ms, 5000);
assert!(!t.is_default);
}
#[test]
fn test_resolve_idle_timeout_unparseable_falls_back_to_default() {
for raw in ["banana", "", "-1", "30s"] {
let t = resolve_idle_timeout(Some(raw.to_string()))
.unwrap_or_else(|| panic!("{:?} should fall back to default", raw));
assert_eq!(t.ms, DEFAULT_IDLE_TIMEOUT_MS);
assert!(t.is_default);
}
}
#[test]
fn test_default_idle_timeout_does_not_close_webdriver_sessions() {
let mut state = DaemonState::new();
assert!(!state.blocks_default_idle_shutdown());
state.backend_type = crate::native::actions::BackendType::WebDriver;
assert!(state.blocks_default_idle_shutdown());
}
#[tokio::test]
async fn test_idle_activity_receives_dashboard_activity() {
let activity = Arc::new(IdleActivity::new());
activity.mark();
tokio::time::timeout(Duration::from_millis(100), activity.notified())
.await
.expect("dashboard input notification should wake the idle loop");
}
#[tokio::test]
async fn test_idle_timer_rearms_from_the_timeout_in_effect_now() {
let activity = IdleActivity::new();
assert!(arm_idle_sleep(&activity).is_none(), "no timeout, no timer");
activity.set_timeout(Some(IdleTimeout {
ms: 3_600_000,
is_default: false,
}));
activity.mark();
activity.set_timeout(Some(IdleTimeout {
ms: 50,
is_default: false,
}));
let sleep = arm_idle_sleep(&activity).expect("timer armed");
let left = sleep.deadline() - tokio::time::Instant::now();
assert!(left <= Duration::from_millis(50), "re-armed for {left:?}");
activity.set_timeout(None);
assert!(arm_idle_sleep(&activity).is_none(), "disabled live");
}
#[tokio::test]
async fn test_command_completion_rearms_expired_idle_timeout() {
let activity = IdleActivity::new();
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(
remaining_idle_timeout(&activity, 1).is_none(),
"the original idle deadline should have expired"
);
activity.mark();
assert!(remaining_idle_timeout(&activity, 100).is_some());
}
#[test]
fn test_daemon_socket_dir_matches_client_namespace() {
let guard = crate::test_utils::EnvGuard::new(&[
"AGENT_BROWSER_SOCKET_DIR",
"XDG_RUNTIME_DIR",
"AGENT_BROWSER_NAMESPACE",
]);
let dir = tempfile::tempdir().unwrap();
guard.set("AGENT_BROWSER_SOCKET_DIR", dir.path().to_str().unwrap());
guard.remove("XDG_RUNTIME_DIR");
guard.set("AGENT_BROWSER_NAMESPACE", "Worktree: One");
let socket_dir = get_daemon_socket_dir();
assert_eq!(socket_dir, crate::connection::get_socket_dir());
assert!(socket_dir.ends_with(
std::path::PathBuf::from("namespaces")
.join("worktree-one")
.join("run")
));
}
#[cfg(windows)]
#[test]
fn test_port_matches_client_algorithm() {
let guard = crate::test_utils::EnvGuard::new(&["AGENT_BROWSER_NAMESPACE"]);
guard.remove("AGENT_BROWSER_NAMESPACE");
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);
}
#[test]
fn test_close_completed_response_requires_actual_close_result() {
let confirmation_response = serde_json::json!({
"success": true,
"data": {
"confirmation_required": true,
"confirmation_id": "close-1",
"action": "close"
}
});
assert!(!close_completed_response("close", &confirmation_response));
}
#[test]
fn test_close_completed_response_accepts_direct_and_confirmed_close() {
let direct = serde_json::json!({
"success": true,
"data": { "closed": true }
});
let confirmed = serde_json::json!({
"success": true,
"data": {
"confirmed": true,
"action": "close",
"result": {
"success": true,
"data": { "closed": true }
}
}
});
assert!(close_completed_response("close", &direct));
assert!(close_completed_response(
crate::connection::INTERNAL_DAEMON_SHUTDOWN_ACTION,
&direct
));
assert!(close_completed_response("confirm", &confirmed));
}
#[tokio::test]
async fn test_background_tick_skips_busy_state() {
let state = Arc::new(tokio::sync::Mutex::new(DaemonState::new()));
let held = state.lock().await;
for _ in 0..100 {
assert!(!spawn_background_tick_if_idle(state.clone(), 30_000));
}
assert_eq!(Arc::strong_count(&state), 1);
drop(held);
assert!(spawn_background_tick_if_idle(state.clone(), 30_000));
let _ = tokio::time::timeout(Duration::from_secs(1), state.lock())
.await
.expect("background tick should release the state lock");
}
#[test]
fn test_no_waitpid_minus_one_in_daemon() {
let source = include_str!("daemon.rs");
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."
);
}
#[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");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
panic!("child did not exit before the deadline");
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e),
}
};
assert_eq!(status.code(), Some(42));
}
#[tokio::test]
async fn test_idle_timeout_fires_despite_drain_interval() {
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 activity = IdleActivity::new();
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;
}
_ = activity.notified() => {
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,
);
}
#[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");
match child.try_wait() {
Ok(None) => {}
other => panic!("expected Ok(None) for running process, got {:?}", other),
}
child.kill().expect("failed to kill child");
std::thread::sleep(std::time::Duration::from_millis(100));
match child.try_wait() {
Ok(Some(_)) => {}
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
),
}
}
}