Skip to content

fix(agent): bash tool spawns no longer hang on an inherited stdin (PRI-3243) - #418

Merged
obra merged 1 commit into
mainfrom
fix/pri-3243-bash-stdin
Sep 26, 2026
Merged

obra merged 1 commit into
mainfrom
fix/pri-3243-bash-stdin

Conversation

@obra

@obra obra commented Sep 26, 2026

Copy link
Copy Markdown
Owner

What

The host process runner spawned every child with stdin as an open pipe that nothing wrote to or closed. A command that falls back to reading stdin blocked until someone killed it. The Dex wedge was head -8 $(find ... | head -1): find matched nothing, head got no file argument, and it waited on stdin for 47 minutes.

This PR:

  • Adds RuntimeProcessOptions.stdin ('ignore' | 'pipe').
  • Makes the host runner default to 'ignore' (/dev/null, so reads get immediate EOF). It opens a pipe only when asked. The bounded host runtime delegates to it, so it gets the same behavior.
  • Has RuntimeStdioClientTransport (MCP servers over stdio) ask for 'pipe'.
  • Has the bash tool and background shell jobs pass 'ignore' explicitly.

The projected container runner does not honor the option yet: container children still get an open stdin pipe. The doc comment on RuntimeProcessOptions.stdin says so. The container side is #415.

Abort and timeout behavior is unchanged from main: the bash tool still kills its direct child only.

Why a separate PR

Split out of #413 per Jesse's decision. #413 did two things: this stdin fix, and a process-group kill model (detached foreground commands, a process-group registry, a shutdown reaper, group kill on abort). Review found the process-group half orphans a subagent's in-flight command tree and its background children when a parent group-kills the subagent. That half moves to a follow-up. #414 and #415 will be rebased onto this PR.

Tests

  • bash.test.ts: real-process tests running the reported repro (head -8 $(find <nonexistent> ...)) and a bare cat. Both return promptly with exit 0. On main, both hang until the 10s test timeout (verified by reverting the implementation with the tests in place).
  • host.test.ts: a started process gets no stdin handle by default and a stdin-reading cat exits 0. With stdin: 'pipe' the handle is writable and cat echoes what was written. The default-stdin test fails on main.
  • shell-job.test.ts and runtime-stdio-transport.test.ts: updated spawn-option expectations.

npm run build, npm run typecheck, npm run lint and npm test all pass locally.

…I-3243)

The host process runner spawned every child with stdin as an open pipe
that nothing wrote to or closed. A command that falls back to reading
stdin, such as `head -8 $(find ... | head -1)` when find matches nothing,
blocked until killed by hand. That is the 47-minute Dex wedge.

Add RuntimeProcessOptions.stdin ('ignore' | 'pipe'). The host runner now
defaults to 'ignore' (/dev/null, immediate EOF) and only opens a pipe when
asked. RuntimeStdioClientTransport, which talks to MCP servers over stdio,
asks for 'pipe'. The bash tool and background shell jobs pass 'ignore'
explicitly. The projected container runner does not honor the option yet;
the doc comment says so.

Abort and timeout handling is unchanged: the bash tool still kills its
direct child only.

Split out of #413; the process-group half of that PR moves to a
follow-up.
@obra
obra merged commit 6615718 into main Sep 26, 2026
1 check passed
@obra
obra deleted the fix/pri-3243-bash-stdin branch September 26, 2026 05:06
obra pushed a commit to ada-sen/lace that referenced this pull request Sep 26, 2026
…e (PRI-3250)

## Problem

obra#418 (PRI-3243) gave RuntimeProcessOptions a stdin?: 'ignore' | 'pipe'
option, defaulted to 'ignore', so a foreground bash spawn doesn't hang on
a command that unexpectedly reads stdin (e.g. head with no file args).
bash.ts passes stdin: 'ignore' explicitly. But that only reaches the
host runtime (HostProcessRunner.start() in host.ts). The container
runtime's ProjectedContainerProcessRunner.start()
(tools/runtime/projected-container.ts) is the RuntimeProcessRunner
implementation used whenever a tool executes inside a container, and it
never read opts.stdin at all: optionsFor() doesn't forward it, and
start() always calls containerManager.execStream(), which always spawns
its underlying docker/container/plane CLI with -i and a piped stdin
(see docker-container.ts's, apple-container.ts's and plane-runtime.ts's
execStream() implementations). Nothing ever wrote to or closed that
pipe, so a container-run command reading stdin because it got zero file
args would hang forever, same shape as PRI-3243, just one layer down and
unaffected by that fix. This is the container-runtime half PRI-3250
calls out as left out of obra#418's scope.

Some execStream consumers genuinely need the piped stdin (an in-container
MCP server driven interactively via RuntimeStdioClientTransport-equivalent
plumbing, or ContainerExecFileSystem/ContainerExecNetworkClient's own
low-level exec-with-stdin paths, which already write + end the pipe
correctly and are unaffected). This fix only changes the
ProjectedContainerProcessRunner.start()/exec() path that backs the bash
tool (and other RuntimeProcessRunner callers) in container mode.

## Fix

- ProjectedContainerProcessRunner.start() now ends the execStream
  handle's stdin immediately (with an error listener to swallow EPIPE)
  whenever opts.stdin !== 'pipe' (the default), mirroring
  HostProcessRunner's 'ignore' semantics without needing to change the
  three container runtimes' own execStream() signatures.
- The returned RuntimeProcessHandle.stdin is undefined in that case, so
  a caller can't be misled into writing to an already-ended stream; it's
  only the live handle when the caller opted into stdin: 'pipe'.

## Tests

Added to
packages/agent/src/tools/runtime/__tests__/projected-container.test.ts:

- 'ends the container stdin pipe immediately when the caller does not
  opt into it' — asserts end() is called once, the stream is
  writableEnded, and the returned handle.stdin is undefined. Fails
  against the pre-fix code (end() never called).
- 'does not end the container stdin pipe when the caller opts into
  stdin: pipe' — asserts the opposite for an MCP-server-shaped stdin:
  'pipe' caller.

Gates: full packages/agent test suite (3656 passed, 35 skipped, 3
pre-existing failures unrelated to this change — confirmed by running
them in isolation on this same worktree before this commit:
resource-resolver.test.ts fails because this fresh worktree has no
built dist/, and track-compaction.fixedpoint.test.ts's one failure was a
parallel-run timing flake that passes standalone), npm run lint, npm run
typecheck — all clean.
obra added a commit that referenced this pull request Sep 26, 2026
…e (PRI-3250) (#415)

* fix(agent): honor RuntimeProcessOptions.stdin in the container runtime (PRI-3250)

## Problem

#418 (PRI-3243) gave RuntimeProcessOptions a stdin?: 'ignore' | 'pipe'
option, defaulted to 'ignore', so a foreground bash spawn doesn't hang on
a command that unexpectedly reads stdin (e.g. head with no file args).
bash.ts passes stdin: 'ignore' explicitly. But that only reaches the
host runtime (HostProcessRunner.start() in host.ts). The container
runtime's ProjectedContainerProcessRunner.start()
(tools/runtime/projected-container.ts) is the RuntimeProcessRunner
implementation used whenever a tool executes inside a container, and it
never read opts.stdin at all: optionsFor() doesn't forward it, and
start() always calls containerManager.execStream(), which always spawns
its underlying docker/container/plane CLI with -i and a piped stdin
(see docker-container.ts's, apple-container.ts's and plane-runtime.ts's
execStream() implementations). Nothing ever wrote to or closed that
pipe, so a container-run command reading stdin because it got zero file
args would hang forever, same shape as PRI-3243, just one layer down and
unaffected by that fix. This is the container-runtime half PRI-3250
calls out as left out of #418's scope.

Some execStream consumers genuinely need the piped stdin (an in-container
MCP server driven interactively via RuntimeStdioClientTransport-equivalent
plumbing, or ContainerExecFileSystem/ContainerExecNetworkClient's own
low-level exec-with-stdin paths, which already write + end the pipe
correctly and are unaffected). This fix only changes the
ProjectedContainerProcessRunner.start()/exec() path that backs the bash
tool (and other RuntimeProcessRunner callers) in container mode.

## Fix

- ProjectedContainerProcessRunner.start() now ends the execStream
  handle's stdin immediately (with an error listener to swallow EPIPE)
  whenever opts.stdin !== 'pipe' (the default), mirroring
  HostProcessRunner's 'ignore' semantics without needing to change the
  three container runtimes' own execStream() signatures.
- The returned RuntimeProcessHandle.stdin is undefined in that case, so
  a caller can't be misled into writing to an already-ended stream; it's
  only the live handle when the caller opted into stdin: 'pipe'.

## Tests

Added to
packages/agent/src/tools/runtime/__tests__/projected-container.test.ts:

- 'ends the container stdin pipe immediately when the caller does not
  opt into it' — asserts end() is called once, the stream is
  writableEnded, and the returned handle.stdin is undefined. Fails
  against the pre-fix code (end() never called).
- 'does not end the container stdin pipe when the caller opts into
  stdin: pipe' — asserts the opposite for an MCP-server-shaped stdin:
  'pipe' caller.

Gates: full packages/agent test suite (3656 passed, 35 skipped, 3
pre-existing failures unrelated to this change — confirmed by running
them in isolation on this same worktree before this commit:
resource-resolver.test.ts fails because this fresh worktree has no
built dist/, and track-compaction.fixedpoint.test.ts's one failure was a
parallel-run timing flake that passes standalone), npm run lint, npm run
typecheck — all clean.

* fix(agent): pass stdin:'pipe' to every container stdin writer; fail loudly, not silently (PRI-3250)

jc's review of #415 (on d449f51) found the fix itself broke container
mode: RuntimeProcessRunner.start() now ends stdin by default (correctly,
per this PR), but the only two production callers that write to stdin never
opted into stdin:'pipe'.

Audited every caller of RuntimeProcessRunner start()/exec() in container
mode:
- bash.ts (foreground bash tool) — passes stdin:'ignore' explicitly
  (PRI-3243); doesn't write to stdin. Fine.
- shell-job.ts (background bash jobs) — passes stdin:'ignore' explicitly;
  doesn't write to stdin. Fine.
- runtime-stdio-transport.ts (in-container MCP server) — passes
  stdin:'pipe' explicitly and genuinely drives it. Fine, unaffected.
- container-exec-fs.ts's writeTextFile — BROKEN. No opts passed at all, so
  the runner defaulted to 'ignore' and writeTextFile always threw
  "write stream unavailable". Broke file_write/file_edit for every
  container-mode runtime.
- container-exec-network.ts's fetch — BROKEN for requests with a body. No
  stdin option passed; `if (handle.stdin)` silently skipped the body write
  when handle.stdin was undefined, so curl sent an empty POST with no
  error raised.
- container-exec-fs.ts's stat/readTextFile/mkdir/readdir and
  file_find.ts/ripgrep_search.ts — all use process.exec(), never write to
  stdin. Unaffected either way.

Fix:
- writeTextFile now passes { stdin: 'pipe' }. Kept (already correct) the
  existing throw when handle.stdin is still missing.
- fetch now passes stdin: 'pipe' only when there's a body to write
  (nothing needed for GET/no-body); added a throw when hasBody is true but
  handle.stdin is missing, matching writeTextFile's fail-loud pattern
  instead of silently sending an empty body.
- Corrected the PR body/commit-message claim that these two call sites
  have their own stdin paths "unaffected by this change" — both go
  through the exact ProjectedContainerProcessRunner.start() this PR
  changes.

Tests:
- The unit-level fakes in container-exec-fs.test.ts and
  container-exec-network.test.ts previously ignored `opts` entirely, so
  they could not observe a missing stdin:'pipe' — exactly how this
  regression passed review. Updated both to honor opts.stdin (live stdin
  only for 'pipe'), matching the real runner's behavior.
- Added composed tests to projected-container.test.ts that wire the REAL
  ProjectedContainerProcessRunner to the REAL ContainerExecFileSystem /
  ContainerExecNetworkClient (the actual production integration seam,
  not a hand-rolled fake): writeTextFile succeeds and the real content
  arrives over the container's stdin pipe; a POST body arrives intact.
  Verified: both fail on d449f51 (writeTextFile throws "write stream
  unavailable"; the POST body test observes an empty write) and pass
  after this fix.
- Strengthened the existing (jc: "vacuous, minor") "does not end the
  container stdin pipe when the caller opts into stdin: pipe" test: it
  previously only asserted `end()` wasn't called and `stdin` was
  reference-equal, both true of pre-fix code too since pre-fix always left
  the pipe live. Now also writes through the returned handle and confirms
  the same bytes arrive on the container-side pipe.

Gates: packages/agent unit suite (366 files / 3663 tests passed, 35
skipped), npm run lint, npm run typecheck — all clean.

* test(agent): cover the container stdin fail-loud branches; trim review narration (PRI-3250)

- container-exec-fs.test.ts / container-exec-network.test.ts: the fakes can
  now withhold stdin even for a 'pipe' caller and count kill() calls. New
  tests assert writeTextFile and a body-bearing fetch kill the process and
  throw "write stream unavailable" instead of writing nothing, and that a
  body-less fetch does not need stdin at all. Both throw tests fail if the
  guard is reverted to the old silent skip.
- projected-container.test.ts: the 'pipe' opt-in test's comment now says
  what it guards (the fix must not end or hide the pipe for 'pipe'
  callers) and that a runner ignoring opts.stdin would also pass it. The
  composed-runner describe block is renamed to say what it covers.
- Comments in container-exec-fs.ts, container-exec-network.ts,
  projected-container.ts and the tests now give the reason for the code
  and drop review-history narration and commit SHAs.
- types.ts: RuntimeProcessOptions.stdin is now documented as honored by
  every runtime, including the projected container runtime.

---------

Co-authored-by: Jesse Vincent <jesse@fsck.com>
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.

1 participant