From 156dd541e68b400013fd5b1acf66747a03c145e5 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 22 Oct 2026 05:12:16 +0200 Subject: [PATCH] hive-priv: count a process being reaped as dead in the build-timeout test The timeout kill was sound; the test's liveness probe was not. is_dead read /proc//stat and counted only state Z as dead. While init reaps a zombie, the task shows state X (EXIT_DEAD) for a few microseconds before its /proc entry goes away. When the poll loop's first probe saw Z and the final assert's re-probe landed in that window and saw X, the test failed at once with "grandchild outlived the timeout". CI run #6059 shows this: the hive-priv binary finished in 2.00s, so the 5s poll never ran. is_dead now counts X as well, so it is monotone (running, Z, X, gone) and the re-probe can no longer flip it back to alive. A unit test pins the Z/X/S/R parsing. Stress loop: the test binary filtered to this test, 2000 runs, 32 in parallel, with 8 busy-loop CPU hogs. Before: 4/2000 failed, all with the CI signature (finished in ~2.01s). After: 0/2000. --- hive-priv/src/container.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/hive-priv/src/container.rs b/hive-priv/src/container.rs index 7c0bb74e..c25bbdf1 100644 --- a/hive-priv/src/container.rs +++ b/hive-priv/src/container.rs @@ -728,18 +728,31 @@ mod tests { ); } - /// Whether `pid` is gone, or only a zombie awaiting its reaper: either - /// way it is no longer running. + /// Whether `pid` is gone, a zombie (`Z`), or being reaped (`X`): none of + /// these runs again, and a process passes through them in that order, + /// so once this is true it stays true. fn is_dead(pid: &str) -> bool { match std::fs::read_to_string(format!("/proc/{pid}/stat")) { Err(_) => true, - // The state letter follows the parenthesised command name. - Ok(stat) => stat - .rsplit_once(") ") - .is_some_and(|(_, rest)| rest.starts_with('Z')), + Ok(stat) => stat_is_dead(&stat), } } + /// The state letter of a `/proc//stat` line, which follows the + /// parenthesised command name, is `Z` or `X`. + fn stat_is_dead(stat: &str) -> bool { + stat.rsplit_once(") ") + .is_some_and(|(_, rest)| rest.starts_with(['Z', 'X'])) + } + + #[test] + fn zombie_and_reaped_states_count_as_dead() { + assert!(stat_is_dead("42 (sleep) Z 1 42 42")); + assert!(stat_is_dead("42 (sleep) X 1 42 42")); + assert!(!stat_is_dead("42 (sleep) S 1 42 42")); + assert!(!stat_is_dead("42 (sleep) R 1 42 42")); + } + /// A child still running at the limit comes back `TimedOut`, and the kill /// reaches its whole process group: the backgrounded grandchild dies too, /// not just the shell that started it.