test(compile): readiness-gate the survivor fixtures — macOS CI race

macOS/luajit failed shutdown_force_kills_outstanding_ledger_groups
with "survivor alive pre-shutdown": on a slow scheduler the leader
(`( trap '' TERM; ... ) & echo $! > pidfile`) can exit before the
backgrounded subshell installs its trap, so the leader-exit
group-TERM kills the "survivor". Linux wins that race consistently;
macOS runners don't. The same race made three sibling tests
vacuously green when it fired (a dead survivor trivially satisfies
"survivor dies" and trivially bounds the drain).

Fix: a shared fixture (survivor_script / survivor_cmdline) writes a
readiness file immediately after `trap` and the leader busy-waits on
it before exiting — the trap is provably installed before any
group-TERM can be sent. Applied to the three process.rs unit
fixtures and the acc08/acc09 acceptance twins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-13 16:46:45 +01:00
parent 50abf91c15
commit 6902959b09
2 changed files with 62 additions and 27 deletions

View File

@ -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();

View File

@ -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;