Skip to content

fix(core): stop the bash tool from hanging forever when a child holds the pipe - #349

Merged
qorzj merged 4 commits into
lessweb:mainfrom
raymondginger2018-sudo:pr/bash-close-event-hang
Sep 15, 2026
Merged

qorzj merged 4 commits into
lessweb:mainfrom
raymondginger2018-sudo:pr/bash-close-event-hang

Conversation

@raymondginger2018-sudo

@raymondginger2018-sudo raymondginger2018-sudo commented Sep 14, 2026

Copy link
Copy Markdown

Problem

Bash calls previously completed only on the child process's close event. A command such as sleep 30 & printf 'hi\n' can exit while a descendant retains stdout or stderr, preventing the tool call from returning promptly. Killing the shell does not guarantee pipe EOF: Unix process groups can outlive their leader, while Windows taskkill may fail once the parent PID is gone.

A cached working directory that has been deleted or converted to an invalid native path also causes subsequent Bash launches to fail.

Changes

Following the separation of process completion and bounded output draining in Codex's codex-rs/core/src/exec.rs:

  • Stop the execution timeout when the shell exits; preserve its exit status and allow stdout/stderr up to a shared 2-second drain deadline. Return immediately when close arrives sooner.
  • On execution timeout, fix the result as a timeout and schedule a 2-second completion fallback before attempting process-tree termination. Late exit events cannot extend that deadline or turn the timeout into success, including on the normal close path.
  • Use one idempotent completion path for close, spawn failure, and forced completion. Clear timers, revoke timeout controls, notify exit once, and ignore late events/output. Timeout changes after exit or timeout have no effect.
  • Keep collected output when drain time expires and append an accurate diagnostic after output truncation, with guidance to use the existing run_in_background: true interface.
  • On Windows, capture cwd with Git Bash builtin pwd -W so virtual mounts are resolved to native paths. Tests use fs.realpathSync.native to normalize Windows 8.3 aliases (RUNNER~1) and long names (runneradmin) before comparison.
  • Validate cached cwd before reuse and before caching a new value; fall back to the project root when the cached directory is invalid. An invalid project root remains a normal spawn error.

Tool parameters and public types are unchanged. Explicit background execution retains its existing behavior. Closing inherited pipes can affect descendants that continue writing; this change does not guarantee those descendants are unaffected.

Validation

Latest commit 698d9e0: CI run 34947474973 passed all six jobs (Node 22/24 on Windows, macOS, and Ubuntu), including the Windows cwd retention/fallback and virtual-mount regressions.

npm run test --workspace @vegamo/deepcode-core: 335 passed, 1 Windows-only test skipped, 0 failures on macOS.

npm run typecheck --workspace @vegamo/deepcode-core: passed.

  • Real-process coverage for stdout-only/stderr-only inherited pipes, successful shell exit before the execution deadline with pipes held beyond it, delayed output with a nonzero shell exit, ordinary execution timeout, and existing background execution.
  • Deterministic event/timer coverage for unsuccessful termination followed by late close, late exit, or no exit at all; checks timeout result consistency, the original completion deadline, preserved output/diagnostics after truncation, single notifications, and ignored late output.
  • cwd coverage for normal reuse, deletion, replacement with a file, and an invalid project root.
  • Core TypeScript check and commit-hook ESLint/Prettier pass on macOS. Windows Git Bash is validated by the PR CI matrix; process-tree tests also cover Windows taskkill dispatch/fallback logic.

… the pipe

executeShellCommand only resolved the tool call inside the child's 'close'
event. A backgrounded descendant (`cmd &`, `nohup ... &`) inherits the tool
call's stdout/stderr pipes and keeps them open after the shell itself exits,
so 'close' never fires; the timeout path called killProcessTree(pid) only,
which is a no-op once that pid is gone (taskkill /PID <pid> /T /F exits
128) and did not settle the promise either. The promise never settled, so
the tool call never returned and the whole CLI session hung permanently
(observed 2026-09-13 23:44; the machine had to be hard-powered-off).

- settle unconditionally 2s after the timeout kill, and 2s after the
  child's 'exit' event, destroying the pipes on the way out
- ignore a late 'close' once settled, and keep the timeout status instead
  of letting 'exit' report exitCode 0 for a killed command
- append a note telling the model to use run_in_background for detached work
- also validate the stored session cwd (fs.statSync().isDirectory()): a
  poisoned cwd (git-bash /tmp/x stored as \tmp\x) made every later spawn
  fail with ENOENT, killing the bash tool for the rest of the session

Regression test: "Bash settles when a background descendant keeps the
output pipe open".
@raymondginger2018-sudo

Copy link
Copy Markdown
Author

@qorzj Could a maintainer approve this PR's pending workflow run?

The CI workflow run 34856826186 is sitting at action_required (first-time fork contributor gate), so no checks have run at all yet: mergeable_state=unstable, check-runs total = 0.

The change is a focused fix in packages/core/src/tools/bash-handler.ts: executeShellCommand only resolved its promise inside the child's 'close' event, and 'close' never fires when a backgrounded descendant (cmd &, nohup ... &) inherits this tool call's stdout/stderr pipes and outlives the shell. The timeout path only called killProcessTree(pid), which is a no-op once that pid is gone, and it did not settle the promise either — so resolve() never ran, the tool call never returned, and the whole CLI session wedged permanently (observed 2026-09-13; the machine needed a hard power-off).

It now settles unconditionally 2s after the timeout kill and 2s after the child's 'exit' event (destroying the pipes on the way out), keeps the timeout status instead of letting a late 'exit' report exitCode: 0, and appends a note telling the model to use run_in_background: true for detached work.

Tests (local): node --import tsx --test --test-concurrency=1 packages/core/src/tests/tool-handlers.test.ts -> 4 tests / 4 pass (the new held-pipe case settles in ~2.1s); tsc --noEmit clean. Diff: +115/-2, 2 files.

Thanks!

@qorzj
qorzj merged commit b37edd7 into lessweb:main Sep 15, 2026
6 checks passed
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