diff --git a/src/process.rs b/src/process.rs index da42b94..e7d04aa 100644 --- a/src/process.rs +++ b/src/process.rs @@ -2749,6 +2749,33 @@ mod tests { .is_ok_and(|o| o.status.success()) } + /// Fixture: background a TERM-ignoring survivor and let the + /// leader exit only after the survivor's trap is INSTALLED + /// (readiness file). Without the gate, a slow scheduler (macOS + /// CI, observed) can deliver the leader-exit group-TERM before + /// the subshell's `trap` runs — killing the "survivor": flaky + /// red for tests that need it alive, vacuous green for tests + /// that assert its death. `redirect` sheds the survivor's + /// stdout/stderr (the acceptance-8 shape); without it the + /// survivor keeps fd1 (the acceptance-9 shape). Returns + /// (script, pidfile). + fn survivor_script(dir: &std::path::Path, redirect: bool) -> (String, std::path::PathBuf) { + let pidfile = dir.join("pid"); + let ready = dir.join("ready"); + let redirect_part = if redirect { + "exec >/dev/null 2>&1; " + } else { + "" + }; + let script = format!( + "( trap '' TERM; : > {ready}; {redirect_part}sleep 30 ) & echo $! > {pid}; \ + while [ ! -e {ready} ]; do sleep 0.01; done", + ready = ready.display(), + pid = pidfile.display(), + ); + (script, pidfile) + } + /// Poll `path` until it holds a parseable pid. Fixture scripts /// write descendant pids there. fn wait_pidfile(path: &std::path::Path) -> i32 { @@ -2859,13 +2886,9 @@ mod tests { // terminal event arrives and the readers finish — only the // ledger's kill(-pgid, 0) probe can catch it. let dir = tempfile::tempdir().expect("tempdir"); - let pidfile = dir.path().join("pid"); let mut sup = ProcessSupervisor::new(); sup.set_group_term_grace(Duration::from_millis(200)); - let script = format!( - "( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}", - pidfile.display() - ); + let (script, pidfile) = survivor_script(dir.path(), true); let id = sup .spawn(sh_group_spec("survivor", &script)) .expect("spawn"); @@ -2938,13 +2961,9 @@ mod tests { // grace is long enough that the ledger cannot fire on its // own — only shutdown's force-kill can reap the survivor. let dir = tempfile::tempdir().expect("tempdir"); - let pidfile = dir.path().join("pid"); let mut sup = ProcessSupervisor::new(); sup.set_group_term_grace(Duration::from_secs(30)); - let script = format!( - "( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}", - pidfile.display() - ); + let (script, pidfile) = survivor_script(dir.path(), true); let id = sup .spawn(sh_group_spec("survivor", &script)) .expect("spawn"); @@ -3007,15 +3026,14 @@ mod tests { // TERM and KEEPS fd1, so the readers stay alive and the old // drain would block ~2s per EXIT_OUTPUT_DRAIN_TIMEOUT (and // then the join would hang). In-drain ledger enforcement - // SIGKILLs at the grace bound instead. + // SIGKILLs at the grace bound instead. Readiness-gated so an + // early leader-exit TERM can't reap the holder and let the + // bound hold vacuously. + let dir = tempfile::tempdir().expect("tempdir"); let mut sup = ProcessSupervisor::new(); sup.set_group_term_grace(Duration::from_millis(300)); - let id = sup - .spawn(sh_group_spec( - "holder", - "( trap '' TERM; sleep 30 ) & echo started", - )) - .expect("spawn"); + let (script, _pidfile) = survivor_script(dir.path(), false); + let id = sup.spawn(sh_group_spec("holder", &script)).expect("spawn"); let stop = Instant::now() + Duration::from_secs(5); let mut max_tick = Duration::ZERO; let mut events = Vec::new(); diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 146e47d..d9c5a13 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -453,22 +453,38 @@ fn acc07_term_trapping_child_falls_to_sigkill() { ); } +/// Fixture: background a TERM-ignoring survivor; the leader exits +/// only after the survivor's trap is INSTALLED (readiness file). +/// Without the gate, a slow scheduler (macOS CI, observed) delivers +/// the leader-exit group-TERM before the subshell's `trap` runs and +/// kills the "survivor" — making these assertions vacuous. Mirrors +/// the process.rs unit fixture. +fn survivor_cmdline(dir: &Path, redirect: bool) -> (String, std::path::PathBuf) { + let pidfile = dir.join("pid"); + let ready = dir.join("ready"); + let redirect_part = if redirect { + "exec >/dev/null 2>&1; " + } else { + "" + }; + let cmdline = format!( + "( trap '' TERM; : > {ready}; {redirect_part}sleep 30 ) & echo $! > {pid}; \ + while [ ! -e {ready} ]; do sleep 0.01; done", + ready = ready.display(), + pid = pidfile.display(), + ); + (cmdline, pidfile) +} + #[test] fn acc08_ledger_reaps_term_ignoring_redirected_survivor() { // The bite: the survivor ignores TERM and sheds its output, so // the terminal event arrives AND the readers finish — only the // liveness probe can catch it. let dir = tempfile::tempdir().unwrap(); - let pidfile = dir.path().join("pid"); let mut s = editor(); - compile_run( - &s, - &format!( - "( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}", - pidfile.display() - ), - dir.path(), - ); + let (cmdline, pidfile) = survivor_cmdline(dir.path(), true); + compile_run(&s, &cmdline, dir.path()); assert!( pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("exited with code 0")), @@ -489,7 +505,8 @@ fn acc09_pipe_holding_survivor_bounded_tick_latency() { // at the grace bound instead of blocking ~2s. let dir = tempfile::tempdir().unwrap(); let mut s = editor(); - compile_run(&s, "( trap '' TERM; sleep 30 ) & echo started", dir.path()); + let (cmdline, _pidfile) = survivor_cmdline(dir.path(), false); + compile_run(&s, &cmdline, dir.path()); let stop = Instant::now() + Duration::from_secs(6); let mut max_tick = Duration::ZERO; let mut done = false;