fix(cli): enforce the ! shell-mode timeout instead of draining to EOF - #81989
briandevans wants to merge 3 commits into
Conversation
`run_bang_command` read `proc.stdout` to EOF on the calling thread and only then called `proc.wait(timeout=timeout)`. EOF arrives only once the shell AND every descendant that inherited the write end of the pipe have closed stdout, so the deadline was reached after the work was already over: the documented 120s ceiling was structurally unreachable and the `return 124` branch was dead code in exactly the case it was written for. The module's own comment above DEFAULT_TIMEOUT states the invariant this broke — "an accidental `!sleep 999` should not wedge the composer" — and tools/environments/base.py carries a written post-mortem of the same construct in the terminal tool that `!` is explicitly modelled on: a backgrounded grandchild (`!npm run dev &`) inherits the pipe via fork() and holds the caller for its whole lifetime even though the shell exited immediately. Drain on a daemon thread and wait on the shell, so `timeout` is a real wall-clock ceiling; stop draining once the shell itself is gone rather than waiting for an EOF that may never arrive; and spawn into a dedicated process group so a timeout or Ctrl+C takes the whole tree down instead of orphaning the grandchildren, matching how tools/environments/local.py already runs the terminal tool's commands.
There was a problem hiding this comment.
Pull request overview
Fixes ! shell-mode timeouts in the interactive CLI by ensuring the timeout is enforced as a wall-clock deadline (rather than being defeated by blocking stdout draining), and adds regression tests to cover the previously-dead timeout path.
Changes:
- Reworks
run_bang_command()to drain output on a daemon thread while the main thread enforces thetimeout, and adds bounded post-exit tail draining. - Spawns
!commands in a dedicated process group and kills the full process tree on timeout / Ctrl+C. - Adds a new
TestBangTimeoutsuite covering timeout enforcement, background-child non-blocking behavior, and descendant termination.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
hermes_cli/bang_shell.py |
Enforces timeout via main-thread wait + daemon drain; adds bounded tail flush and process-tree kill. |
tests/cli/test_bang_shell_mode.py |
Adds regression coverage for timeout + backgrounded-child pipe behavior and descendant kill semantics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| for line in stream: | ||
| if stop.is_set(): | ||
| # Deadline already reported: a grandchild that outlived | ||
| # the shell must not keep writing into the composer. | ||
| break | ||
| last_output = time.monotonic() | ||
| emit(line.rstrip("\n")) |
| Keeps waiting while output is still arriving, so a large tail is not | ||
| truncated, but gives up once the pipe has gone idle — an EOF that | ||
| depends on a backgrounded grandchild may never come at all. | ||
|
|
| class TestBangTimeout: | ||
| """``timeout`` must be a real wall-clock ceiling on the composer. |
…e pipe Breaking out of the drain loop closed the read end of the stdout pipe, so a descendant the user deliberately backgrounded (`!npm run dev &`) took EPIPE on its next write and died — defeating the point of handing the composer back early. Discard post-deadline output instead of closing: nothing lands in the composer after control returned to it, and the descendant runs on. The pipe and its daemon reader are released when that descendant exits. Also state _DRAIN_MAX_TAIL's hard cap in _flush_tail's docstring, which previously implied the tail was never truncated.
|
@copilot All three findings addressed in
|
tests/agent/test_subprocess_env_guard.py flags any os.environ.copy() within 20 lines of a spawn call. The helper's `subprocess.Popen[str]` annotation put the token three lines below the pre-existing raw copy in `_bang_env`, so a guard that had been quiet on this file started reporting those two untouched lines as new offenders. Defining the helper below its caller restores the distance; no behavior change, and the guard keeps its teeth.
|
Exact-head comment on #81989 head 66407d0. KEEP wall-clock bang-shell timeout enforced by waiting on the shell while a daemon drain thread reads the pipe — inline CHECK: closing the read end must not kill a still-running background grandchild the user intentionally detached (author already addressed prior Copilot note at c68cd1f — re-verify on this head). CHECK: writer callbacks stay line-oriented and never insert bang output into conversation history. Author briandevans not kvnloo. No competing PR from me. Adjacent #98400 is parser/gate tests only — do not remint a second timeout PR. |
What does this PR do?
hermes_cli/bang_shell.py::run_bang_commanddrains the merged output pipe to EOF on the calling thread, and only then applies the deadline:for line in proc.stdoutreturns only when the pipe reaches EOF, and EOF arrives only once the shell and every descendant that inherited the write end viafork()have closed stdout. Soproc.wait(timeout=timeout)is reached only after the work is already over: the documented ceiling (DEFAULT_TIMEOUT = 120) is structurally unreachable, and thereturn 124branch is dead code in exactly the case it was written for.The repo states this invariant against itself, twice.
bang_shell.py's own comment, directly aboveDEFAULT_TIMEOUT:!sleep 999wedges the composer for the full 999s today.tools/environments/base.pycarries a written post-mortem of this exact construct, for the terminal tool that!is explicitly modelled on (same approval gate, per this module's docstring):The terminal tool was hardened with a bounded drain.
!shell mode reuses none of it. ([Bug]: Terminal tool hangs indefinitely when using setsid + disown pattern to launch background services #8340 is closed and is cited here as precedent for the failure mode, not as the issue this fixes.)User-visible symptom. A user types
!npm run dev &at the composer. In a normal terminal the prompt returns instantly. In Hermes the composer never comes back — no output, no timeout message, indefinitely — because the backgrounded grandchild is holding the pipe the CLI is reading to EOF. The only escape is Ctrl+C, and since the command shared the CLI's process group, that also killed the server they had just launched. Measured before this change, withtimeout=3:sleep 8rc=0after 8.0s — the 3s deadline never fired(sleep 8 &) ; echo startedrc=0after 8.0s — the shell exited immediately; the call was held for the grandchild's whole lifetimeReached by default, with no flag, on every platform, by the ordinary act of backgrounding a process from the composer.
Related Issue
No open issue — found by reading the module against its own stated ceiling.
Type of Change
Changes Made
hermes_cli/bang_shell.pytimeoutbecomes a real wall-clock ceiling instead of a post-EOF formality. This is the shapehermes_cli/main.py::_run_with_idle_timeoutalready uses in this package — reader on a daemon thread, main thread owning the deadline,rc = 124on expiry._flush_tail()keeps reading while output is still arriving, so a large tail is never truncated, but gives up after an idle grace (_DRAIN_IDLE_GRACE = 0.3, bounded by_DRAIN_MAX_TAIL = 2.0) rather than waiting on an EOF that a backgrounded grandchild may never deliver. This mirrors the terminal tool's drain intools/environments/base.py, which stops ~300ms after bash exits for the same reason. The idle window is measured from the last line seen or the shell's exit, whichever is later, so a command that is silent and then prints on its way out is not raced.start_new_session=Trueon POSIX,CREATE_NEW_PROCESS_GROUPon Windows — and kill the whole group on expiry via_kill_bang_process_tree().proc.kill()alone signals only the shell wrapper, leaving the grandchildren running and still holding the pipe. This matches howtools/environments/local.pyalready spawns (start_new_session=True) and kills (_kill_process→os.killpg) the terminal tool's own commands.KeyboardInterruptthrough the same tree kill.tools/environments/base.pygives the reason directly: once a child is in its own process group, letting the interrupt propagate without killing the group reparents it to init and leaves it running as an orphan.windows_hide_flags()value, never replacing it — the child still needsCREATE_NO_WINDOW.windows_detach_flags_without_breakaway()is the existing public helper for exactlyCREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW;CREATE_BREAKAWAY_FROM_JOBis deliberately not used here.read()would deadlock on the buffered reader's lock — the very hang class being removed.!npm run dev &server this change exists to keep alive. The cost is one blocked daemon thread, released as soon as that descendant exits.tests/cli/test_bang_shell_mode.py— newTestBangTimeout(the file previously contained zero occurrences oftimeout,124, orsleep; the 124 path was entirely uncovered).How to Test
Red before / green after, both directions run explicitly. Against unpatched
bang_shell.py, all three regression tests fail — and fail for the stated reason, not a mismatched assertion:After the fix:
55 passed in 11.53s(was 50).The five tests:
test_timeout_is_enforced_on_a_silent_command—run_bang_command("sleep 5", timeout=1)returns124, emitstimed out after 1s, and returns in under 3s.test_returns_when_the_shell_exits_with_a_background_child— the [Bug]: Terminal tool hangs indefinitely when using setsid + disown pattern to launch background services #8340 shape,(sleep 5 &) ; echo started. The generoustimeout=60is deliberate: this pins the stop draining once the shell is gone contract, not the deadline, so it cannot pass merely because the deadline fired.test_timeout_kills_descendants_not_just_the_shell— a grandchild that would touch a marker file 2s in never gets to; POSIX-gated onsys.platform.test_a_backgrounded_descendant_survives_the_composer_returning— the other half of the contract: handing the composer back must not kill what!cmd &launched. Asserts both that no post-return line reaches the writer and that the descendant ran to completion. Fails against a variant that closes the read end, withdescendant was killed by the read end closing.test_a_tail_printed_after_a_silence_is_streamed_in_full— not a red-before case, and labelled as such in its docstring. It guards the risk this fix itself introduces: that bounding the drain could truncate output.Full-suite check, run in a throwaway
git worktreeso nothing touched the live checkout:1 failed, 935 passed, 1 skipped(tests/cli/)origin/main@372b3b7:1 failed, 931 passed, 1 skippedSame single failure both sides —
tests/cli/test_resume_quiet_stderr.py::test_session_not_found_goes_to_stdout_in_full_mode, a pre-existing ordering-dependent baseline (it passes in isolation on clean main). Delta is exactly the 4 added tests. Tested on macOS 15 / Python 3.11; the Windows legs are by inspection against the existing_subprocess_compathelpers, so the cross-platform box below is left unchecked.Sibling-site sweep
Grepped Channel A (
cli.py,hermes_cli/,gateway/,tui_gateway/,utils.py) for a live pipe drained by iteration —for line in proc.stdout,iter(proc.stdout,.stdout.readline(). Four sites, one defect:hermes_cli/bang_shell.pytimeouthermes_cli/main.py::_run_with_idle_timeout::_readerdaemon=Truethread while the main thread owns the deadline in its ownproc.wait(timeout=5)loop, so EOF never gates the callertui_gateway/host_supervisor.py::_drain_stdout/_drain_stderrstart_new_session=True; no deadline depends on EOFtui_gateway/server.py::_drain_stdout/_drain_stderrthreading.Thread(..., daemon=True), spawn alreadywindows_hide_flags()+start_new_session=Truehermes_cli/main.py::_run_npm_watching_for_engine_failureiteratesproc.stderron the calling thread as well, but takes notimeoutat all and calls a bareproc.wait()— nothing is being defeated there, so it is a different concern and deliberately left alone.bang_shell.pyis the only site in this class where the pipe drain runs on the calling thread and a documented deadline depends on it.Duplicate check
Text search across the open PR corpus (
gh search prs, which reaches all open PRs rather than a recent slice) for every symbol this diff centres on, re-run immediately before pushing:run_bang_command→ 0,bang_shell→ 0,shell mode timeout→ 0,wedge the composer→ 0,composer hang→ 0.bang shellreturns only #76749 (fix(cron): isolate approval context per job), whose only hunk in this file is inbang_shell_enabled()— a different function, no overlap.Searching the helpers this PR calls into,
_kill_git_process_treeandwindows_detach_flags_without_breakaway, surfaces open work onhermes_cli/_subprocess_compat.pyandtools/environments/local.py(#79235, #69083, #43253, #42868, #43252, #65990). None of them touchhermes_cli/bang_shell.py; this PR only calls those helpers and does not modify either file, so there is no textual overlap in either direction.git log origin/main --since='30 days ago' -- hermes_cli/bang_shell.py tests/cli/test_bang_shell_mode.pyshows a single commit,9704ed86c13(the feature itself), so this is not a contested file.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — rantests/cli/in full against a clean-origin/mainbaseline (see above), not the entire suiteDocumentation & Housekeeping
docs/, docstrings) —run_bang_command's docstring now states whattimeoutguarantees and why it is enforced this waycli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Awindows_hide_flags(), tree kill viataskkill /T /F, process-group test POSIX-gated), but only executed on macOS, so leaving this unticked rather than claiming Windows verificationScreenshots / Logs
Behaviour change at the composer, with the default 120s ceiling: