Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 107 additions & 12 deletions crates/agentflare-jobs/src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +196 to +214

Copy link
Copy Markdown

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 TERM can 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 neither TERM nor the later KILL.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-jobs/src/supervisor.rs` around lines 192 - 200, Update the
local signal closure around signal and descendant_pids so descendants are
discovered and their verified process identities retained before signaling the
process group. Use that preserved target set for both TERM and the later KILL
phase, rather than relying on a post-signal descendant_pids traversal after the
parent may have exited.

}
#[cfg(windows)]
{
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 kill_graceful before sh executes setsid. The marker then stays absent even if descendant termination is broken.

Add a readiness marker from inside the setsid child. Use a timeout that permits that marker. Assert readiness before checking that the delayed marker was not written.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-jobs/src/supervisor.rs` around lines 237 - 260, The test
spawn_times_out_and_kills_a_descendant_that_escaped_into_its_own_process_group
must establish that the setsid child started before timing out. Add a readiness
marker created inside the setsid child, use a nonzero timeout sufficient for
that marker, and assert its presence before waiting and verifying the delayed
marker remains absent.

assert!(
!marker.exists(),
"a descendant that escaped into its own process group must still be \
killed on timeout, not just orphaned"
);
}
}
Loading