diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 14091ca..0e06e4e 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -2269,11 +2269,15 @@ impl ProxyManager { let expected_start_time = crate::proxy_storage::get_proxy_config(&proxy_id) .and_then(|config| config.browser_pid_start_time); let still_the_same_browser = match system.process(sysinfo::Pid::from_u32(browser_pid)) { - Some(process) => expected_start_time.is_none_or(|expected| { - // Same PID, different process: the browser died and its PID was reused. - process.start_time() == expected - }), - None => false, + // An exited-but-unreaped browser still occupies its PID in this + // snapshot; only a genuinely live process counts. + Some(process) if crate::proxy_storage::snapshot_process_is_alive(process) => { + expected_start_time.is_none_or(|expected| { + // Same PID, different process: the browser died and its PID was reused. + process.start_time() == expected + }) + } + _ => false, }; if still_the_same_browser { alive_pids.push(browser_pid); diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index fc3c2a5..ca3e850 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -210,6 +210,16 @@ pub fn generate_proxy_id() -> String { ) } +/// A process that has exited but whose parent has not reaped it. It still owns +/// a PID and still appears in the process table, so an existence check reports +/// it as alive — forever, since the workers and browsers we spawn are detached +/// and never waited on. Treating it as running is what would keep a dead +/// browser's worker alive on Linux, where zombies persist for the whole life of +/// the parent process. +fn process_is_zombie(process: &sysinfo::Process) -> bool { + process.status() == sysinfo::ProcessStatus::Zombie +} + pub fn process_start_time(pid: u32) -> Option { use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; let pid = sysinfo::Pid::from_u32(pid); @@ -219,7 +229,17 @@ pub fn process_start_time(pid: u32) -> Option { true, ProcessRefreshKind::nothing(), ); - system.process(pid).map(sysinfo::Process::start_time) + system + .process(pid) + .filter(|process| !process_is_zombie(process)) + .map(sysinfo::Process::start_time) +} + +/// Whether a process from an existing `System` snapshot is genuinely alive. +/// Callers that already hold a full scan use this instead of re-querying, but +/// must still exclude zombies the same way `process_start_time` does. +pub fn snapshot_process_is_alive(process: &sysinfo::Process) -> bool { + !process_is_zombie(process) } pub fn is_process_running(pid: u32) -> bool { @@ -510,6 +530,47 @@ mod tests { assert_eq!(proxy_config_age_secs(&format!("proxy_{}_1", now + 600)), 0); } + /// An exited child that nobody has waited on stays in the process table as a + /// zombie for as long as its parent lives — and everything we spawn is + /// detached and never waited on. Reporting one as running would keep a dead + /// browser's worker alive for the whole life of the app. + #[cfg(unix)] + #[test] + fn an_exited_but_unreaped_child_is_not_running() { + let child = std::process::Command::new("true") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn child"); + let pid = child.id(); + let start_time = process_start_time(pid); + + // Deliberately leak the handle: waiting would reap the zombie and destroy + // the very condition under test. + std::mem::forget(child); + + let mut observed_dead = false; + for _ in 0..100 { + if !is_process_running(pid) { + observed_dead = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!( + observed_dead, + "an exited child that was never waited on must not read as running (PID {pid})" + ); + // The same must hold for the identity check the workers actually use. + assert!(!process_identity_matches(pid, start_time)); + + // Reap it now so the test process doesn't leave one behind. + unsafe { + libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0); + } + } + #[test] fn configs_written_before_owner_start_times_still_load() { let legacy = serde_json::json!({