-
Notifications
You must be signed in to change notification settings - Fork 0
fix(jobs): kill_graceful reaches descendants that escaped into their own process group #394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,23 +145,73 @@ impl Supervisor { | |
| } | ||
| } | ||
|
|
||
| /// PIDs of every live descendant of `pid` (children, grandchildren, ...), | ||
| /// walked via `/proc/<pid>/task/*/children` -- Linux-only (no `/proc` on | ||
| /// macOS/Windows). Needed because a descendant that called | ||
| /// `process_group(0)` on itself (see `agent_launch::run_captured`, which | ||
| /// does exactly this so *it* can kill a runaway agent CLI) has left the | ||
| /// process group `-{pid}` targets below; signaling it by group alone won't | ||
| /// reach it, so `kill_graceful` also signals every PID this returns | ||
| /// directly. | ||
| #[cfg(target_os = "linux")] | ||
| fn descendant_pids(pid: u32) -> Vec<u32> { | ||
| fn children_of(pid: u32) -> Vec<u32> { | ||
| let task_dir = format!("/proc/{pid}/task"); | ||
| let Ok(entries) = std::fs::read_dir(&task_dir) else { | ||
| return Vec::new(); | ||
| }; | ||
| let mut out = Vec::new(); | ||
| for entry in entries.flatten() { | ||
| let children_path = entry.path().join("children"); | ||
| let Ok(contents) = std::fs::read_to_string(&children_path) else { | ||
| continue; | ||
| }; | ||
| out.extend( | ||
| contents | ||
| .split_whitespace() | ||
| .filter_map(|s| s.parse::<u32>().ok()), | ||
| ); | ||
| } | ||
| out | ||
| } | ||
|
|
||
| let mut all = Vec::new(); | ||
| let mut frontier = children_of(pid); | ||
| while let Some(next_pid) = frontier.pop() { | ||
| all.push(next_pid); | ||
| frontier.extend(children_of(next_pid)); | ||
| } | ||
| all | ||
| } | ||
|
|
||
| #[cfg(not(target_os = "linux"))] | ||
| fn descendant_pids(_pid: u32) -> Vec<u32> { | ||
| Vec::new() | ||
| } | ||
|
|
||
| fn kill_graceful(child: &mut std::process::Child, kill_after: Duration) { | ||
| #[cfg(unix)] | ||
| { | ||
| let pid = child.id(); | ||
| let _ = Command::new("kill") | ||
| .arg("-s") | ||
| .arg("TERM") | ||
| .arg("--") | ||
| .arg(format!("-{pid}")) | ||
| .status(); | ||
| let signal = |sig: &str, pid: u32| { | ||
| let _ = Command::new("kill") | ||
| .arg("-s") | ||
| .arg(sig) | ||
| .arg("--") | ||
| .arg(format!("-{pid}")) | ||
| .status(); | ||
| for descendant in descendant_pids(pid) { | ||
| let _ = Command::new("kill") | ||
| .arg("-s") | ||
| .arg(sig) | ||
| .arg("--") | ||
| .arg(descendant.to_string()) | ||
| .status(); | ||
| } | ||
| }; | ||
| signal("TERM", pid); | ||
| std::thread::sleep(kill_after); | ||
| let _ = Command::new("kill") | ||
| .arg("-s") | ||
| .arg("KILL") | ||
| .arg("--") | ||
| .arg(format!("-{pid}")) | ||
| .status(); | ||
| signal("KILL", pid); | ||
| } | ||
| #[cfg(windows)] | ||
| { | ||
|
|
@@ -184,3 +234,48 @@ fn kill_graceful(child: &mut std::process::Child, kill_after: Duration) { | |
| let _ = child.kill(); | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| // `setsid` moves the grandchild into a brand-new session/process group of | ||
| // its own -- exactly what `agent_launch::run_captured` does to its own | ||
| // child so *it* can kill a runaway agent CLI independently. That leaves | ||
| // the grandchild outside the group `kill -TERM -- -<pid>` targets, so a | ||
| // fix that only sends the group signal would leave it running past the | ||
| // timeout. Only `/proc` (Linux) backs `descendant_pids`, so this is | ||
| // scoped to Linux CI rather than xfailing on macOS/Windows runners. | ||
| #[cfg(target_os = "linux")] | ||
| #[test] | ||
| fn spawn_times_out_and_kills_a_descendant_that_escaped_into_its_own_process_group() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let marker = dir.path().join("still-alive"); | ||
| let mut supervisor = Supervisor::new( | ||
| "test".to_string(), | ||
| "sh".to_string(), | ||
| vec![ | ||
| "-c".to_string(), | ||
| format!("setsid sh -c 'sleep 5; touch {}' & wait", marker.display()), | ||
| ], | ||
| vec![], | ||
| None, | ||
| 0, // times out immediately — the point is what happens on timeout | ||
| 0, | ||
| dir.path().to_path_buf(), | ||
| ); | ||
|
|
||
| let (output, _state) = supervisor.spawn().unwrap(); | ||
| assert!(output.timed_out, "should report timeout"); | ||
| // The 5s `sleep` inside the setsid'd grandchild would still have it | ||
| // alive if `kill_graceful` only signaled the direct child's process | ||
| // group; give it a moment past `spawn()` returning, then confirm it | ||
| // never reached its `touch`. | ||
| std::thread::sleep(Duration::from_millis(500)); | ||
|
Comment on lines
+251
to
+274
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Ensure the test starts the detached descendant before timeout. At Line 249, a zero timeout can call Add a readiness marker from inside the 🤖 Prompt for AI Agents |
||
| assert!( | ||
| !marker.exists(), | ||
| "a descendant that escaped into its own process group must still be \ | ||
| killed on timeout, not just orphaned" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Preserve descendant targets before the group signal.
At Line 193, the process-group
TERMcan terminate the direct child before Line 194 reads its descendants. The detached descendant is then reparented and no longer appears below/proc/<pid>. It receives neitherTERMnor the laterKILL.Discover descendants before sending the group signal. Retain verified process identities for the forced-kill phase. Do not rely only on a second traversal after the parent can exit.
🤖 Prompt for AI Agents