From 3b0bafa05abd1c1594859dd2ba77f490859f58de Mon Sep 17 00:00:00 2001 From: user Date: Sat, 29 Aug 2026 09:34:39 +0800 Subject: [PATCH] fix(cli): probe macOS process group via pgrep before terminating On macOS kill(-pgid, 0) returns ESRCH for an orphaned process group whose only remaining members are TERM-resistant children, so wait_for_process_group_exit believed the group had exited and terminate_worker returned early, letting SIGKILL follow a vanished leader. The macOS branch of process_group_alive now enumerates group members with pgrep -g (Apple's ps -g is a no-op unless unix2003 compat mode is active) and requires at least one live member judged with the existing macos_process_state_allows_escalation stat semantics; a failed or empty query fails closed. The Linux kill(-pgid, 0) + EPERM path is unchanged. Test: cargo test --locked -p bitfun-cli --bin bitfun dispatch::runner --jobs 4 (7 passed, 0 failed; the unix-only cancellation_does_not_escalate_after_term_exits_the_verified_leader scenario is excluded on the windows host and runs via remote CI CLI Tests(macos)) AI: This change was assisted by AI and lightly tested. --- src/apps/cli/src/dispatch/runner.rs | 58 +++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 537783eef3..ea4db62e68 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -204,14 +204,58 @@ fn wait_for_process_group_exit(process_group: i32) -> bool { #[cfg(unix)] fn process_group_alive(process_group: i32) -> bool { - // SAFETY: signal 0 performs liveness/permission checking only. - if unsafe { libc::kill(-process_group, 0) } == 0 { - return true; + // macOS: kill(-pgid, 0) returns ESRCH for an orphaned process group whose + // only remaining members are TERM-resistant children, while Linux keeps + // reporting the group as alive while any member lives. Enumerate the group + // with pgrep instead and require at least one live member, judged with the + // same process-state semantics as process_alive; a failed or empty query + // means the group disappeared during the check. (Apple's ps -g is a no-op + // unless the unix2003 compatibility mode is active, so pgrep -g is the + // reliable PGID probe on macOS.) + #[cfg(target_os = "macos")] + { + let output = Command::new("pgrep") + .args(["-g", &process_group.to_string()]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + // pgrep exits 1 when no processes matched: a vanished or empty + // process group is not alive. + return false; + } + // pgrep lists PIDs; a zombie still matches, so cross-check each + // candidate with ps and require at least one live member. + return String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .any(|member_pid| { + let state = Command::new("ps") + .args(["-p", &member_pid.to_string(), "-o", "stat="]) + .output(); + match state { + Ok(output) if output.status.success() => { + macos_process_state_allows_escalation(&String::from_utf8_lossy( + &output.stdout, + )) + } + _ => false, + } + }); + } + + #[cfg(not(target_os = "macos"))] + { + // SAFETY: signal 0 performs liveness/permission checking only. + if unsafe { libc::kill(-process_group, 0) } == 0 { + return true; + } + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) } - matches!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EPERM) - ) } #[cfg(any(test, target_os = "macos"))]