From 56b70341fba74a14f149ce8945d8f0cb86143501 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Tue, 22 Sep 2026 20:55:58 +0800 Subject: [PATCH 1/2] fix(session-host): skip identity checks for a zombie group leader Stop failed on macOS after it had killed its own worker. The group drain loop re-checked the leader's identity whenever `process_is_running` said the pid was still there, and the two platforms disagree about a zombie: the Linux implementation reads `/proc//stat` and answers "not running", while every other Unix asks `kill(pid, 0)` and answers "running". The drain loop then asked macOS libproc for the identity of a zombie, which answers nothing, so `verify_group_leader_identity` reported `could not verify the identity of process N` and the whole stop failed. That is the exact case it exists for: the leader a stop just terminated, still unreaped by the process that spawned it. Decide it once as `verify_live_group_leader_identity`: the identity is checked while the leader is still an active group member, and skipped once it is a zombie. A zombie's pid cannot be reused until it is reaped, so no other process can have taken that identity over, and the checks before signalling are untouched: `terminate_recorded_unix_group` still verifies the leader twice before it signals the group. Seven `rebon-session-host` tests cover this path. They only run in the `test` job, which is macos-14 and gated on a tag or a pull request, so this is the first change to reach them. --- .../src/process_liveness.rs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/rebon-session-host/src/process_liveness.rs b/crates/rebon-session-host/src/process_liveness.rs index 851b44f..ea86280 100644 --- a/crates/rebon-session-host/src/process_liveness.rs +++ b/crates/rebon-session-host/src/process_liveness.rs @@ -83,8 +83,12 @@ pub fn recorded_process_tree_is_running( ) -> anyhow::Result { #[cfg(unix)] if owner_detached_group { - verify_group_leader_identity(pid, expected_identity)?; - return Ok(!active_unix_group_members(pid)?.is_empty()); + let active = active_unix_group_members(pid)?; + if active.is_empty() { + return Ok(false); + } + verify_live_group_leader_identity(pid, expected_identity, &active)?; + return Ok(true); } #[cfg(not(unix))] let _ = owner_detached_group; @@ -208,6 +212,23 @@ fn verify_group_leader_identity(pid: u32, expected_identity: Option<&str>) -> an } } +/// Verify the recorded owner's identity while it is still an active group +/// member. A leader that already exited keeps its pid until it is reaped, so +/// no other process can have taken that identity over — and macOS libproc +/// answers nothing for a zombie, which would otherwise fail every stop that +/// killed its own worker and then waited for the group to drain. +#[cfg(unix)] +fn verify_live_group_leader_identity( + pid: u32, + expected_identity: Option<&str>, + active: &[u32], +) -> anyhow::Result<()> { + if active.contains(&pid) { + verify_group_leader_identity(pid, expected_identity)?; + } + Ok(()) +} + #[cfg(target_os = "linux")] fn parse_linux_proc_stat(stat: &str) -> anyhow::Result<(char, u32)> { let command_end = stat @@ -332,10 +353,8 @@ fn wait_for_recorded_unix_group_exit( ) -> anyhow::Result<()> { let start = std::time::Instant::now(); loop { - if process_is_running(pid) == Some(true) { - verify_group_leader_identity(pid, expected_identity)?; - } let active = active_unix_group_members(pid)?; + verify_live_group_leader_identity(pid, expected_identity, &active)?; if active.is_empty() { return Ok(()); } From 41546ac3189e387845bec0298d8c62742614618f Mon Sep 17 00:00:00 2001 From: Quorafind Date: Tue, 22 Sep 2026 21:22:33 +0800 Subject: [PATCH 2/2] fix(session-host): treat a zombie as exited when asking about liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change drained a process group without re-checking a zombie leader, and one test stopped failing, but six stop_job tests still did. They take the paths that never look at group membership at all: `terminate_process_best_effort` waits with `wait_for_pid_exit`, and `terminate_process`, `wait_for_tree_exit` and `recorded_process_is_running` all ask `process_is_running` whether the worker is still there. That question had two answers. Linux reads the state field of `/proc//stat` and reports a zombie as exited; every other Unix asks `kill(pid, 0)`, which answers for a zombie too, so a terminated worker stayed "running" until its parent reaped it — and on the stop path the parent is the process doing the waiting. Every such wait ran to its timeout and reported `worker exit was not verified`. macOS now separates the two with the identity read it already has: libproc describes a live process and answers nothing for one whose parent has not reaped it, so a pid this process may signal but cannot describe is an exited child. `terminate_recorded_unix_group` also verifies the leader through the active-member list, the same rule the drain loop uses, instead of probing a pid that may already be a zombie. Ref: the macOS-only failures in the release `test` job. --- .../src/process_liveness.rs | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/rebon-session-host/src/process_liveness.rs b/crates/rebon-session-host/src/process_liveness.rs index ea86280..37ea599 100644 --- a/crates/rebon-session-host/src/process_liveness.rs +++ b/crates/rebon-session-host/src/process_liveness.rs @@ -375,15 +375,14 @@ fn terminate_recorded_unix_group( expected_identity: Option<&str>, timeout: Duration, ) -> anyhow::Result<()> { - let _leader_alive = verify_group_leader_identity(pid, expected_identity)?; - let active_before = active_unix_group_members(pid)?; - if active_before.is_empty() { + let active = active_unix_group_members(pid)?; + if active.is_empty() { return Ok(()); } - // Recheck after enumeration. The scan can take long enough for the leader - // to exit or for a stale pid to become visibly mismatched; never turn that - // observation into a signal against the newly observed process group. - let _leader_alive = verify_group_leader_identity(pid, expected_identity)?; + // The scan can take long enough for the leader to exit, so the check sits + // directly after it and speaks only for a live leader: a zombie reports no + // identity on macOS, and its pid cannot be reused until it is reaped. + verify_live_group_leader_identity(pid, expected_identity, &active)?; let group = checked_unix_pid(pid)? .checked_neg() .ok_or_else(|| anyhow::anyhow!("cannot represent process group {pid}"))?; @@ -589,8 +588,16 @@ fn linux_stat_is_zombie(stat: &str) -> bool { #[cfg(all(unix, not(target_os = "linux")))] pub fn process_is_running(pid: u32) -> Option { - let pid = libc::pid_t::try_from(pid).ok()?; - match unix_kill(pid, 0) { + let checked = libc::pid_t::try_from(pid).ok()?; + match unix_kill(checked, 0) { + // Existence is not liveness: `kill(pid, 0)` answers for a zombie too, + // and a zombie has exited. Linux reads that from the state field of + // `/proc//stat`; macOS libproc still describes a live process and + // answers nothing for one whose parent has not reaped it yet, so the + // identity read is what separates the two here. A pid we may signal but + // cannot describe is an exited child, not a running worker. + #[cfg(target_os = "macos")] + Ok(true) => Some(process_identity(pid).is_some()), Ok(exists) => Some(exists), // EPERM means the target exists but is unsignalable. unix_kill keeps // that distinct from ESRCH; liveness must report it as present.