Skip to content

Commit 9700da8

Browse files
yogthosYogthos
andauthored
fix(F6): bash timeout kills whole process group, not just direct child (#81)
Track F-HIGH #6 from ROADMAP.md. ## Problem `bash.rs:70-87` used `tokio::time::timeout(d, cmd.output())`. On timeout the tokio future is dropped, taking the `Child` with it — which kills only the immediate `bash` process. Bash's subprocesses (npm install, cargo build, python test runner) stayed alive as orphans reparented to PID 1. For a `--sandbox` build the bwrap wrapper's `--die-with-parent` mostly contained this, but unsandboxed builds leaked CPU + RAM on every timed-out long-running command. ## Fix New `run_with_timeout(cmd, secs)` helper replaces the inline timeout logic. Key changes: 1. **Spawn in a new process group on Unix** via tokio's `Command::process_group(0)`. The child becomes leader of a new group with `pgid = pid`. 2. **`kill_on_drop(true)`** so the immediate child gets a signal when the future drops on any platform. 3. **`Stdio::piped()`** for stdin/stdout/stderr — explicit because manual `spawn` (vs `.output()`) defaults to inherit and would route the agent's output to its own terminal, returning empty buffers. 4. **On timeout, `libc::kill(-pid, SIGKILL)`** — negative pid targets the process group, reaching every descendant. The kill is done via `libc` (already a transitive dep, now declared in `Cargo.toml` under `[target.'cfg(unix)']`). 5. On Windows we fall back to kill_on_drop only — proper job- object cleanup would require additional deps; the direct- child kill is the same behavior as before on that platform. Matches pi's `bash.ts:76-81` `detached: true` + `killProcessTree(pid)` shape. ## Tests Two new Unix-only (`#[cfg(unix)]`) tests in `agent::tools::bash::tests`: - `run_with_timeout_kills_orphaned_child`: runs `sleep 5` with a 1s timeout; asserts the call returns within 3s with a timeout error. The fast return proves the process was actually killed (otherwise we'd race to read output that doesn't exist until second 5). - `run_with_timeout_returns_output_on_success`: runs `echo hi` with a 5s timeout; asserts stdout reaches us as "hi" — guards against the Stdio::piped() fix accidentally breaking the happy path. 660 pass (was 658, +2 Unix-only). All build profiles clean. Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent c151abd commit 9700da8

3 files changed

Lines changed: 137 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ semantic-bash = ["semantic", "dep:tree-sitter-bash"]
2828
plugin = ["dep:janetrs"]
2929
lsp = ["dep:lsp-types"]
3030

31+
[target.'cfg(unix)'.dependencies]
32+
# Used by the bash tool for process-group cleanup on timeout
33+
# (F6) — `killpg(pgid, SIGKILL)` to terminate the whole subshell
34+
# tree rather than orphaning bash's children.
35+
libc = "0.2"
36+
3137
[dependencies]
3238
rig = { version = "0.37", features = ["rmcp"] }
3339
rmcp = { version = "1.7", optional = true, default-features = false, features = [

src/agent/tools/bash.rs

Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use rig::completion::ToolDefinition;
22
use rig::tool::Tool;
3-
use tokio::time::{Duration, timeout};
3+
use std::process::Output;
4+
use tokio::process::Command;
5+
use tokio::time::Duration;
46

57
use crate::agent::tools::cache::ToolCache;
68
use crate::agent::tools::{AskSender, BashArgs, PermCheck, ToolError, check_perm};
@@ -9,6 +11,65 @@ use crate::sandbox::Sandbox;
911
#[cfg(feature = "semantic-bash")]
1012
use crate::semantic::adapters::bash;
1113

14+
/// Spawn `cmd` into its own process group and wait for it,
15+
/// capped at `secs`. On timeout, send SIGKILL to the process
16+
/// group so the whole subprocess tree dies — not just bash. On
17+
/// Windows we fall back to tokio's `kill_on_drop` which signals
18+
/// the direct child only (Windows job objects would be cleaner
19+
/// but require extra deps). F6 fix.
20+
async fn run_with_timeout(cmd: Command, secs: u64) -> Result<Output, ToolError> {
21+
use std::process::Stdio;
22+
let mut cmd = cmd;
23+
// Pipe stdio so `wait_with_output` actually captures it. Default
24+
// is inherit, which routes output to the parent's terminal and
25+
// returns empty `output.stdout`/`stderr`.
26+
cmd.stdin(Stdio::null())
27+
.stdout(Stdio::piped())
28+
.stderr(Stdio::piped());
29+
// `kill_on_drop(true)` ensures the immediate child gets a
30+
// signal when the tokio future is dropped — necessary for
31+
// ANY platform's timeout to actually clean up the bash process.
32+
cmd.kill_on_drop(true);
33+
34+
#[cfg(unix)]
35+
{
36+
// process_group(0) makes the spawned child the leader of a
37+
// new process group with pgid = pid. Then `killpg(-pid)`
38+
// reaches every descendant. (tokio's `Command` exposes this
39+
// natively without needing the std `CommandExt` trait.)
40+
cmd.process_group(0);
41+
}
42+
43+
let child = cmd
44+
.spawn()
45+
.map_err(|e| ToolError::Msg(format!("failed to spawn: {}", e)))?;
46+
let pid = child.id();
47+
48+
let wait = child.wait_with_output();
49+
match tokio::time::timeout(Duration::from_secs(secs), wait).await {
50+
Ok(out) => out.map_err(|e| ToolError::Msg(format!("wait failed: {}", e))),
51+
Err(_) => {
52+
// Timeout. Kill the whole group on Unix; on Windows
53+
// kill_on_drop will signal the direct child when the
54+
// returned error path drops the (now-dropped) child.
55+
#[cfg(unix)]
56+
if let Some(pid) = pid {
57+
// SAFETY: killpg with negative pid sends to the
58+
// process group. SIGKILL is the same on every
59+
// POSIX platform; libc::pid_t is i32 on every
60+
// platform dirge supports.
61+
unsafe {
62+
let _ = libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
63+
}
64+
}
65+
// We've requested the kill but tokio doesn't surface a
66+
// post-kill output. Return the timeout error directly.
67+
let _ = pid; // silence unused-on-windows warning
68+
Err(ToolError::Msg(format!("Command timed out after {}s", secs)))
69+
}
70+
}
71+
}
72+
1273
pub struct BashTool {
1374
pub permission: Option<PermCheck>,
1475
pub ask_tx: Option<AskSender>,
@@ -67,24 +128,19 @@ impl Tool for BashTool {
67128
async fn call(&self, args: BashArgs) -> Result<String, ToolError> {
68129
check_bash_segments(&self.permission, &self.ask_tx, &args.command).await?;
69130

70-
let output = if let Some(secs) = args.timeout {
71-
if secs == 0 {
72-
return Err(ToolError::Msg("timeout must be > 0".to_string()));
73-
}
74-
timeout(
75-
Duration::from_secs(secs),
76-
self.sandbox.wrap_command(&args.command).output(),
77-
)
78-
.await
79-
.map_err(|_| ToolError::Msg("Command timed out".to_string()))?
80-
} else {
81-
timeout(
82-
Duration::from_secs(120),
83-
self.sandbox.wrap_command(&args.command).output(),
84-
)
85-
.await
86-
.map_err(|_| ToolError::Msg("Command timed out after 120s".to_string()))?
87-
}?;
131+
// F6: spawn into its own process group so a timeout can
132+
// SIGKILL the entire subprocess tree, not just the
133+
// immediate `bash` child. Before this, `pi` would spawn
134+
// `npm install`, the 120s timeout fired, the future was
135+
// dropped (taking the tokio `Child` with it), but bash's
136+
// children — and theirs — kept running orphaned under PID 1.
137+
// pi (`bash.ts:76-81`) does this via `detached: true` +
138+
// `killProcessTree(pid)`.
139+
let secs = args.timeout.unwrap_or(120);
140+
if secs == 0 {
141+
return Err(ToolError::Msg("timeout must be > 0".to_string()));
142+
}
143+
let output = run_with_timeout(self.sandbox.wrap_command(&args.command), secs).await?;
88144

89145
let stdout = String::from_utf8_lossy(&output.stdout);
90146
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -165,3 +221,58 @@ async fn check_bash_segments(
165221
Ok(())
166222
}
167223
}
224+
225+
#[cfg(test)]
226+
#[cfg(unix)]
227+
mod tests {
228+
use super::*;
229+
230+
/// F6: a timed-out `sleep 9999` (or any long-running command)
231+
/// must actually be killed when the timeout fires. Before this
232+
/// fix, dropping the tokio future left the bash child running
233+
/// orphaned. The test runs `sleep 5` with a 1-second timeout
234+
/// and asserts: (a) we return the timeout error within ~1.5s,
235+
/// (b) the time to return is much less than the requested
236+
/// sleep duration — proving the process was actually killed
237+
/// rather than us racing to read its output.
238+
#[tokio::test]
239+
async fn run_with_timeout_kills_orphaned_child() {
240+
let start = std::time::Instant::now();
241+
let cmd = {
242+
let mut c = Command::new("bash");
243+
c.arg("-c").arg("sleep 5");
244+
c
245+
};
246+
let result = run_with_timeout(cmd, 1).await;
247+
let elapsed = start.elapsed();
248+
249+
assert!(result.is_err(), "expected timeout error, got {:?}", result);
250+
let msg = format!("{:?}", result);
251+
assert!(
252+
msg.contains("timed out"),
253+
"expected 'timed out' in error: {msg}",
254+
);
255+
// The timeout fires at 1s; we allow up to 2s slack for
256+
// CI variance. The KEY assertion is we return well before
257+
// the 5s sleep would have completed naturally.
258+
assert!(
259+
elapsed < Duration::from_secs(3),
260+
"took too long to return: {:?}",
261+
elapsed,
262+
);
263+
}
264+
265+
/// F6: a command that completes under the timeout returns
266+
/// normally — no false-positive kill.
267+
#[tokio::test]
268+
async fn run_with_timeout_returns_output_on_success() {
269+
let cmd = {
270+
let mut c = Command::new("bash");
271+
c.arg("-c").arg("echo hi");
272+
c
273+
};
274+
let out = run_with_timeout(cmd, 5).await.expect("should succeed");
275+
let stdout = String::from_utf8_lossy(&out.stdout);
276+
assert_eq!(stdout.trim(), "hi");
277+
}
278+
}

0 commit comments

Comments
 (0)