Skip to content

fix(F6): bash timeout kills whole process group, not just direct child - #81

Merged
yogthos merged 1 commit into
mainfrom
fix/f6-bash-pgid-cleanup
May 21, 2026
Merged

fix(F6): bash timeout kills whole process group, not just direct child#81
yogthos merged 1 commit into
mainfrom
fix/f6-bash-pgid-cleanup

Conversation

@yogthos

@yogthos yogthos commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Track F-HIGH #6. Previous tokio::time::timeout(d, cmd.output()) left bash's subprocess tree orphaned on timeout. New run_with_timeout helper spawns in a new process group on Unix and sends killpg(-pid, SIGKILL) on timeout. 2 new Unix-only tests, 660 pass.

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.
@yogthos
yogthos merged commit 9700da8 into main May 21, 2026
1 check passed
@yogthos
yogthos deleted the fix/f6-bash-pgid-cleanup branch May 21, 2026 04:24
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
dirge-code#81)

Track F-HIGH dirge-code#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant