Skip to content

fix(agent): enforce a foreground bash timeout (PRI-3251) - #414

Merged
obra merged 4 commits into
obra:mainfrom
ada-sen:fix-pri-3251-bash-foreground-timeout
Sep 26, 2026
Merged

obra merged 4 commits into
obra:mainfrom
ada-sen:fix-pri-3251-bash-foreground-timeout

Conversation

@ada-sen

@ada-sen ada-sen commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Based directly on main. It no longer depends on #413. The stdin half of #413 merged as #418. The process-group half (detached spawn, process-group registry, shutdown reap) is deferred to #413 and is not in this PR.

Problem

The bash tool description has long said a sync command "is subject to a runtime timeout (tens of seconds) and is killed if it exceeds it". Nothing enforced that. A hung foreground command blocked until the caller aborted. (PRI-3251)

Fix

  • Default timeout: 600s. It is configurable per instance with LACE_BASH_FOREGROUND_TIMEOUT_MS, which can only lower it. The value is clamped to 1000..600000. A value that isn't a plain positive decimal integer (10s, 0, 1e3, 0x3e8, 1000.0) falls back to 600s. Surrounding whitespace is ignored. Both cases log a warning.
  • Per-call override: a new timeoutMs field, bounded 1000..600000.
  • Behaviour change to note: any foreground call that omits timeoutMs and runs longer than 600s (or the lowered instance default) is now killed. Before this PR it ran indefinitely.
  • On expiry: SIGTERM the shell the tool spawned, then SIGKILL it 2s later. On main the foreground child is not detached and has no process group of its own, so only the direct child is signaled. The SIGKILL timer is never cleared when the call settles. The abort path uses the same code, so a TERM-ignoring shell still dies after an abort settles the call early.
  • Bounded by timeoutMs + 2s: once the SIGKILLed shell exits, the tool stops waiting on stdout/stderr. It destroys the streams and completes with the output captured so far, so a descendant holding the pipes can't keep the call open. Destroying the streams also closes the tool's read ends, so that descendant's later writes fail with EPIPE instead of landing in a settled call.
  • Shell already exited, pipes still open: this happens when a background child holds stdout/stderr. The result reports the shell's real exit code, and the message says the pipes stayed open and suggests background=true or redirecting that process's output. It does not say "killed", and the tool ends the call at the timeout with no grace wait. To tell this case apart, the host runtime now exposes an optional exited promise on process handles. It resolves when the process exits, before its pipes close. Only the host runner implements exited, so under the projected container runtime this pipes-held message can't fire, and such a call gets the regular killed-shell message instead.
  • Result shape: timedOut: boolean, plus timeoutMessage when it is true. A timed-out call is always a tool failure. When the shell was killed, the message says that processes it started may still be running. On main that is true, because only the shell is signaled.

Tests

All tests use real processes. Pid-file tests write $$ and count a process as dead only on ESRCH. They are in packages/agent/src/tools/bash.test.ts:

  • The exact tool description with the env var unset. This pins the 600000 default.
  • Schema rejects timeoutMs 999 and 600001 and accepts 1000 and 600000.
  • Env values: unset gives 600000 (no warning). 120000 is used as given (no warning). 1800000 becomes 600000 and 500 becomes 1000 (clamped, with a warning). 10s, 0, 0x3e8, 1e3, 1000.0 and the empty string become 600000 (with a warning). 120000\n is accepted without a warning. The configured default applies to a call that omits timeoutMs.
  • A hung command times out at 1s and keeps its partial output.
  • A TERM-ignoring command (trap '' TERM; ... exec sleep 60) on the timeout path ends between timeout+grace-100ms and timeout+grace+1.5s, and its pid is gone.
  • A setsid-escaped descendant holding the pipes doesn't hold the call past timeout+grace.
  • After the call settles, a setsid-escaped descendant's late write to stdout fails (non-zero status), because the tool closed its read ends. Cleanup kills that descendant's process group.
  • A shell that exited 0 while a background child holds the pipes gets exitCode: 0 and the pipes-held message, ending before timeout + 1.5s.
  • A fast command is not flagged.
  • A TERM-ignoring command is SIGKILLed after an abort settles the call.

Mutation checks: removing the SIGKILL fails both TERM-ignoring tests. Removing the stop-waiting step fails the escaped-descendant test. Removing both destroy() calls in settleWithoutWaitingForPipes fails the late-write test (the write succeeds, status 0). The settle bound itself doesn't depend on destroy(), since that function marks the streams ended directly, so the escaped-descendant timing test still passes without it.

Changes in this round

Gates

npm run build, lint, typecheck and format:check pass. bash.test.ts has 57 tests, all passing. The full agent suite passes (3853 passed, 27 skipped; serial group 283 passed, 12 skipped). The cli and ent-protocol suites pass. test:coverage passes except track-compaction.fixedpoint.test.ts, which hits its 5s timeout on my loaded local machine. It fails the same way on untouched main and is green in main's CI.

Not in scope

@obra

obra commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Review from jc: two competing adversarial reviewers, then a verifier that tried to refute each finding. Verdict: changes needed.

  1. Important: settling clears the SIGTERM→SIGKILL grace timer. (clearPendingTimers, bash.ts:198/:282 with :336-342)
    • If the tool settles inside the 2s window, SIGKILL is never sent. That regresses fix(agent): reap detached bash process groups within the parent's kill window (PRI-3243) #413's abort path, where the SIGKILL timeout was never cancelled.
    • Reproduced on 68a730c:
      • Abort of trap '' TERM; sleep 47.71 returned "aborted", and the sleep was still alive 4s later.
      • The timeout path with a TERM-ignoring sh -c left both processes alive.
    • Fix: on settle, clear only the timeout timer. Let the grace timer fire (ESRCH is caught), or unref() it.
  2. Important: same vacuous pid tests as fix(agent): reap detached bash process groups within the parent's kill window (PRI-3243) #413. (bash.test.ts:825, :893)
    • echo $ writes $, so the orphan assertions can't fail. Use echo \$\$, Number.isInteger, and treat only ESRCH as dead.
  3. Important: the timeout doesn't actually bound the call.
    • Settlement waits for stdout/stderr end. A descendant that escaped the group (setsid, a double-forking daemon) and still holds the pipes keeps the call blocked forever.
    • Fix: after SIGKILL, destroy the streams and complete, so the call settles by timeoutMs + KILL_GRACE_MS. Add a setsid test.
  4. Decision needed: the new 60s default hard-kills every foreground call that omits timeoutMs.
    • Builds, installs, clones and test runs over 60s will now fail mid-run.
    • I'm asking Jesse. Options: a higher default (e.g. the 600s max) or a configurable one, called out in release notes.
  5. Minor: test gaps.
    • No test covers SIGKILL escalation (the pipeline test uses exec sleep, which dies on TERM), the default, or the schema bounds (999 / 600_001).
  6. Minor: misleading timeout message.
    • When bash exits 0 but a background child holds stdout, the call waits the whole timeout and says "Command timed out" with exit 0.
    • Suggest a distinct message pointing at background=true or redirecting output.

Refuted: an unbounded container exec before the timer arms, a container stdin gap (that's #413's), and an abort-during-grace message detail.

@obra

obra commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Jesse's decision on the default: make it configurable (a per-instance setting), with a 600s default. Please call out the behaviour change in the PR description. https://claude.ai/code/session_01PvaV8ypCvokp6DCcW6rT7f

ada-sen added a commit to ada-sen/lace that referenced this pull request Sep 25, 2026
…aped descendants; configurable default (PRI-3251)

jc's review of obra#414 found:

1. Important: settling cleared the SIGKILL grace timer. host.ts passes
   context.signal straight into node's child_process.spawn({signal}),
   which fires its own abort-driven kill-and-error-the-child handling the
   instant the AbortSignal fires — independent of whether the real OS
   process has exited. That rejects `completion` almost immediately
   (observed ~1ms), and the old clearPendingTimers() unconditionally
   cancelled the still-pending SIGKILL timer on any settle. A TERM-ignoring
   process (trap) never got the SIGKILL and survived.

   Fix: split the timer-clearing so only the foreground-timeout-detection
   timer is cancelled on settle; the SIGKILL grace timer is left to run to
   completion (killProcessOrGroup's ESRCH is caught if there's truly
   nothing left) and is unref()'d so it can never itself keep the process
   alive. Reproduced first as a failing test: abort a `trap '' TERM; sleep
   30` — settles in ~1ms, and the trapped process is still alive 3s later
   on 68a730c; fixed version confirms it's actually dead (ESRCH).

2. Important: same vacuous pid tests as obra#413 (bare `echo $` instead of
   `echo $$`). Fixed the same way: `$$`, `Number.isInteger`, ESRCH-only
   "dead" check.

3. Important: the timeout didn't bound a descendant that escaped the
   process group entirely (setsid) and still held the stdout/stderr pipes
   — waiting on stdout/stderr 'end' would hang for that descendant's whole
   lifetime. Fix: once the SIGKILL grace period elapses, stop waiting —
   destroy the streams and force the call to settle with whatever's
   already captured, bounding the call by timeoutMs (or abort) +
   KILL_GRACE_MS. New test: `(setsid sleep 30 &) ; sleep 30` with
   timeoutMs 1000 times out on old code (15s test timeout) and settles
   in ~2s on the fix.

4. Jesse's call on the default: configurable per-instance
   (LACE_BASH_FOREGROUND_TIMEOUT_MS, same env-var-with-clamped-fallback
   pattern as delegate.ts's LACE_WORKSPACE_MAX_PER_PARENT), default 600s
   instead of a hardcoded 60s that hard-killed every foreground call
   omitting timeoutMs (builds, installs, clones, long test runs). Tool
   description now reflects the configured value and the env var name.
   New test: a lowered env-configured default (500ms) is honored for a
   command that omits timeoutMs; times out (15s) on old code (which
   ignores the env var and uses the old 60s hardcoded default) and passes
   in ~0.5s+grace on the fix.

Per-call override bounds (1s-600s) are unchanged; the per-call ceiling and
the new default happen to be the same value (600s) — the ceiling was
already generous, and Jesse's call was to give omitted-timeoutMs calls
that same generous budget rather than the old conservative 60s.

Gates: packages/agent unit suite, npm run lint, npm run typecheck.
@ada-sen
ada-sen force-pushed the fix-pri-3251-bash-foreground-timeout branch from 68a730c to a37884d Compare September 25, 2026 19:37
@ada-sen

ada-sen commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 8c987b9f4 (rebased onto the reworked #413, now a37884d on top of 9cc0a9f):

  1. Settling cleared the SIGKILL grace timer: split clearPendingTimers — only the foreground-timeout-detection timer is cancelled on settle now; the SIGKILL grace timer runs to completion (unref'd, ESRCH caught). Reproduced first: abort of trap '' TERM; sleep 30 settles in ~1ms (Node's own spawn({signal}) abort handling fires independent of the real process) and the trap survivor is alive 3s later on 68a730c; dead (ESRCH) on the fix.
  2. Vacuous pid tests: same $$/Number.isInteger/ESRCH-only fix as fix(agent): reap detached bash process groups within the parent's kill window (PRI-3243) #413, at both call sites.
  3. Timeout didn't bound an escaped descendant: after the SIGKILL grace period, streams are now force-destroyed and the call settles regardless. New setsid test: (setsid sleep 30 &) ; sleep 30 with timeoutMs: 1000 times out the 15s test on 68a730c, settles in ~2s on the fix.
  4. Default: made it a per-instance setting, LACE_BASH_FOREGROUND_TIMEOUT_MS, default 600s (same pattern as delegate.ts's LACE_WORKSPACE_MAX_PER_PARENT), called out in the tool description. Per-call bounds (1s–600s) unchanged.

Every new/changed test verified to fail on 68a730c and pass on 8c987b9f4.

The bash tool description has long promised that sync calls are killed
after a runtime timeout, but nothing enforced one: a hung foreground
command blocked until the caller aborted.

- Sync calls now time out. Per-call `timeoutMs` is bounded 1000..600000.
  Calls that omit it get the per-instance default: 600s, which operators
  can lower with LACE_BASH_FOREGROUND_TIMEOUT_MS. The env value is clamped
  to 1000..600000 and a non-positive-integer value (e.g. "10s", "0") falls
  back to 600s; both cases log a warning.
- On timeout the tool SIGTERMs the shell it spawned and SIGKILLs it 2s
  later. The foreground child is not detached and has no process group of
  its own on main, so only the direct child is signaled. The SIGKILL timer
  is never cleared when the call settles, so a TERM-ignoring shell still
  dies after an abort settles the call early.
- After the SIGKILLed shell exits, the tool stops waiting on stdout/stderr
  (destroys the streams and completes), so a descendant that keeps the
  pipes open cannot hold the call past timeoutMs + 2s.
- If the shell had already exited when the timeout fires, the pipes are
  held by a background process it started. The result then reports the
  shell's exit code and says so, suggesting background=true or redirecting
  that process's output, instead of claiming the command was killed. The
  host runtime exposes the new optional `exited` promise on process
  handles so the tool can tell the two cases apart.
- Results carry `timedOut` and, when it is true, `timeoutMessage`; a
  timed-out call is always a tool failure.
@obra
obra force-pushed the fix-pri-3251-bash-foreground-timeout branch from a37884d to 5ec9db7 Compare September 26, 2026 05:31
…call (PRI-3251)

Covers the stdout/stderr destroy() in settleWithoutWaitingForPipes: once
the tool stops waiting on the pipes it must also close its read ends, so a
setsid'd descendant's late write fails instead of feeding a settled call.
…MS (PRI-3251)

Number() also parsed "0x3e8", "1e3" and "1000.0" as 1000, so those values
were used silently. Require digits only (after trimming) and warn and fall
back to the default for anything else.
@obra
obra marked this pull request as ready for review September 26, 2026 06:04
@obra
obra merged commit 37fcb07 into obra:main Sep 26, 2026
1 check passed
ada-sen added a commit to ada-sen/lace that referenced this pull request Sep 26, 2026
…l window (PRI-3243)

This PR now covers only the detached-process-group / kill-model rework.
The stdin fix shipped separately as obra#418; obra#414 (foreground timeout) and
obra#415 (container-runtime stdin) are already on main. This is rebuilt from
scratch on top of that main, not rebased.

jc's review of the previous version of this PR found the detached-group
fix incomplete: a subagent kill only gives the subagent 500ms
(job-control.ts's killJob/killAllRunningJobs, both called with
waitMs: 500, forceKill: true) before SIGKILLing it outright. The prior
attempt's shutdown()-time reap used a 2s SIGTERM->SIGKILL grace period —
comfortably past that 500ms deadline, so a TERM-ignoring descendant of a
detached foreground bash command would still be mid-poll when the
parent's SIGKILL ended the subagent process outright, abandoning the
detached group exactly as before.

Fix, same shape as before but re-timed to fit the real deadline:
- bash.ts spawns its foreground command detached (POSIX) again and kills
  the whole process group (kill(-pid)), not just the shell, on both abort
  and timeout.
- process-group-registry.ts tracks every detached command's pid;
  main.ts's shutdown() calls killAllTrackedProcessGroups() as the very
  first thing it does (before workspace teardown, before anything else
  that can hang), SIGTERMing every tracked group, polling for it to empty
  out, and SIGKILLing any survivor.
- The grace period for that escalation is now 150ms, not 2s -- far under
  the parent's 500ms deadline with room for signal-delivery and poll-loop
  latency, so the reap reliably finishes before the parent's SIGKILL can
  land on the subagent process itself.

Why this design and not the others considered:
- A pgid-file/handshake so the PARENT tracks and kills descendant groups
  itself would work too, but needs a new IPC surface between parent and
  child and still has to solve the same "how fast can this finish" problem
  -- it doesn't remove the timing constraint, just moves the deadline to a
  different process. Reaping from the subagent's own SIGTERM handler needs
  no protocol change: the subagent already gets the SIGTERM, and it already
  owns the process-group-registry that named these pids.
- Not detaching at all would lose the ability to kill a pipeline (e.g.
  sleep | cat) from a plain abort inside the SAME process, which is the
  behavior obra#414's review already validated for this PR; dropping it would
  regress that.
- A larger grace period is what jc's review actually found broken: it has
  to be short specifically because the parent's budget is short.

Tests:
- New: agent-process.shutdown-process-group.e2e.test.ts. Spawns a real
  lace-agent process, gives it a foreground bash command with BOTH a
  plain `&` background child and a foreground pipeline whose first stage
  traps (ignores) SIGTERM, then kills the agent process the same way
  job-control.ts's killJob does -- SIGTERM, wait only 500ms, SIGKILL if
  still running -- and asserts neither descendant survives.
  - Fails on plain main (no detached spawn at all: the bash-tool child is
    never signaled when the agent process exits, so it's simply
    reparented to init).
  - Fails on main + a detached-only spawn with no registry/shutdown reap
    (confirmed by temporarily reverting just the main.ts change): the
    detached group is abandoned to init the moment the agent process
    exits, since nothing ever signals a group outside the agent's own.
  - Passes with the full fix.
- New: process-group-registry.test.ts (tracking/untracking, SIGKILL
  escalation for a TERM-ignoring group within the 150ms/500ms budget, and
  no-op on an empty or already-untracked registry).
- bash.test.ts: added the process-group abort test (pipeline member killed
  on abort, not just the shell) and the no-unhandled-rejection-on-abort
  test.

Gates: packages/agent full suite (`npm run test`) -- 374 files / 3851
tests passed, 35 skipped (unit phase) + 48 files / 276 tests passed, 20
skipped (E2E phase), 0 failed. `npm run lint`, `npm run typecheck` --
both clean. No orphan processes left on the host after the suite run.
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.

2 participants