fix(agent): enforce a foreground bash timeout (PRI-3251) - #414
Merged
Merged
Conversation
Owner
|
Review from jc: two competing adversarial reviewers, then a verifier that tried to refute each finding. Verdict: changes needed.
Refuted: an unbounded container exec before the timer arms, a container stdin gap (that's #413's), and an abort-during-grace message detail. |
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
force-pushed
the
fix-pri-3251-bash-foreground-timeout
branch
from
September 25, 2026 19:37
68a730c to
a37884d
Compare
Contributor
Author
|
Addressed in 8c987b9f4 (rebased onto the reworked #413, now a37884d on top of 9cc0a9f):
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
force-pushed
the
fix-pri-3251-bash-foreground-timeout
branch
from
September 26, 2026 05:31
a37884d to
5ec9db7
Compare
…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
marked this pull request as ready for review
September 26, 2026 06:04
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.timeoutMsfield, bounded 1000..600000.timeoutMsand runs longer than 600s (or the lowered instance default) is now killed. Before this PR it ran indefinitely.mainthe 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.background=trueor 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 optionalexitedpromise on process handles. It resolves when the process exits, before its pipes close. Only the host runner implementsexited, so under the projected container runtime this pipes-held message can't fire, and such a call gets the regular killed-shell message instead.timedOut: boolean, plustimeoutMessagewhen 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. Onmainthat 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 onESRCH. They are inpackages/agent/src/tools/bash.test.ts:timeoutMs999 and 600001 and accepts 1000 and 600000.120000is used as given (no warning).1800000becomes 600000 and500becomes 1000 (clamped, with a warning).10s,0,0x3e8,1e3,1000.0and the empty string become 600000 (with a warning).120000\nis accepted without a warning. The configured default applies to a call that omitstimeoutMs.trap '' TERM; ... exec sleep 60) on the timeout path ends between timeout+grace-100ms and timeout+grace+1.5s, and its pid is gone.setsid-escaped descendant holding the pipes doesn't hold the call past timeout+grace.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.exitCode: 0and the pipes-held message, ending before timeout + 1.5s.Mutation checks: removing the SIGKILL fails both TERM-ignoring tests. Removing the stop-waiting step fails the escaped-descendant test. Removing both
destroy()calls insettleWithoutWaitingForPipesfails the late-write test (the write succeeds, status 0). The settle bound itself doesn't depend ondestroy(), since that function marks the streams ended directly, so the escaped-descendant timing test still passes without it.Changes in this round
main(fix(agent): honor RuntimeProcessOptions.stdin in the container runtime (PRI-3250) #415, container stdin handling). It merged cleanly.destroy()and tightened env parsing to plain decimal integers.main. All fix(agent): reap detached bash process groups within the parent's kill window (PRI-3243) #413 commits are dropped, including the.finallyprocess-group untrack line that crashed vitest.main.Gates
npm run build,lint,typecheckandformat:checkpass.bash.test.tshas 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:coveragepasses excepttrack-compaction.fixedpoint.test.ts, which hits its 5s timeout on my loaded local machine. It fails the same way on untouchedmainand is green inmain's CI.Not in scope
background: true) jobs. They are routed to the job system before they reach this tool.