fix(esc): make ESC cancel Bash + streaming within ~50ms - #130
Merged
Conversation
ESC presses had to wait the full Bash timeout (often 10–60s) or the remainder of a model turn before having any effect. Three architectural gaps were stacked: 1. ``_call_tool`` ran SYNCHRONOUS tools inline on the event loop, so while ``subprocess.run`` blocked, no other task — including the ESC key handler that fires ``abort_controller.abort()`` — could run. 2. ``_bash_call`` used ``subprocess.run(timeout=...)`` with no PID handle and no abort_signal reference. Even if abort were called, there was no path to kill the subprocess; the call returned only when the command finished or hit its timeout. 3. ``_run_model_turn`` did not take an abort_signal at all, and ``streaming_query`` only polled the signal at the OUTER while-loop boundary (between turns). A long model response would emit every chunk before the abort was noticed. Fixes mirror the TS reference (``typescript/src/utils/ShellCommand.ts`` + the ``shouldBypassPermissions`` / per-chunk abort checks in ``typescript/src/services/api/claude.ts``): * ``_call_tool``: wrap sync tools in ``asyncio.to_thread`` so the event loop stays responsive while Bash runs. * ``_bash_call``: replace ``subprocess.run`` with ``Popen(..., start_new_session=True)`` + a 50ms poll loop that watches ``abort_signal.aborted`` and the timeout; on either, ``os.killpg(SIGTERM)`` → 2s grace → SIGKILL the whole process group. Sync entry preserved so ``skill.py``/tests keep working. * ``_run_model_turn``: accept ``abort_signal``; check ``.aborted`` at the top of every event from ``call_model`` and return early. ``streaming_query`` re-checks after the inner loop so a turn that aborted mid-stream yields ``aborted`` instead of ``turn_complete``. Smoke benchmarks: * ``sleep 30`` aborted via context.abort_controller: **227ms** through the full orchestrator path (was ~30s). * Streaming 100ms/chunk × 20 chunks aborted at 250ms: **303ms** until ``aborted`` event, no further ``text``/``turn_complete``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
singlaamitesh
pushed a commit
to singlaamitesh/clawcodex
that referenced
this pull request
Jul 7, 2026
…llation-latency fix(esc): make ESC cancel Bash + streaming within ~50ms
singlaamitesh
pushed a commit
to singlaamitesh/clawcodex
that referenced
this pull request
Jul 7, 2026
Highlights PR agentforce314#130 (ESC cancels Bash + streaming within ~50ms) along with the recent agentforce314#129 (diff color-bar full-width) and agentforce314#128 (bypass-permissions outside-paths) fixes. Appends current Python codebase stats: 857 files, 167,034 lines.
singlaamitesh
pushed a commit
to singlaamitesh/clawcodex
that referenced
this pull request
Jul 7, 2026
Before this change, the bash supervisor (`_run_bash_with_abort`) set `interrupted=True` on BOTH ESC-abort and timeout. The two paths then collapsed into one tool_result shape: content ending with `<error>Command was aborted before completion</error>` and `is_error=True`. The model treated timed-out commands as user-cancelled and tended to retry them on resume. The TS reference at `typescript/src/utils/ShellCommand.ts:135-141, 302, 323-328` and `typescript/src/tools/BashTool/BashTool.tsx:610-630` keeps the two paths distinct via the result-label exit code: * ESC abort: SIGKILL → exit code 137 → `interrupted=true` → `is_error=true` → `<error>Command was aborted before completion</error>` appended. * Timeout: SIGKILL (kill is the same; only the label differs in TS at `#doKill(SIGTERM)`) → exit code label 143 → `interrupted=false` → `is_error=false` → stderr prepended with `Command timed out after <duration>`; no `<error>` tag. Mirror this in Python: * `_BashRunResult.interrupted` is now set only on ESC-abort; the timeout case sets only `timed_out=True`. * Both paths SIGKILL the process group immediately (TS's `#doKill` always calls `treeKill(pid, 'SIGKILL')` regardless of the `code` label argument). The previous SIGTERM-with-grace-then-SIGKILL escalation is gone for parity and to keep ESC latency under the ~50ms target tracked by PR agentforce314#130. * `_bash_call` produces two distinct payloads: the ESC branch keeps `interrupted=True, exit_code=-1, is_error=True`; the new timeout branch sets `timed_out=True, exit_code=143, is_error=False`, prepends the duration marker (formatted via `format_duration(timeout_s * 1000)` for TS-byte parity — `30s`, `2m 0s`, `1h 5m 12s`) to whatever stderr was captured with a single-space separator (mirrors TS `prependStderr` at `ShellCommand.ts:56-58`), and leaves stderr otherwise unmodified (no `.strip()`). * `_KILL_GRACE_S` is renamed to `_KILL_REAP_TIMEOUT_S` to reflect its new role: a bound on the post-SIGKILL kernel-reap wait, not a SIGTERM→SIGKILL grace. * `_bash_map_result_to_api` is unchanged. Its existing `interrupted = output.get("interrupted", False)` check naturally handles the split because timeout payloads no longer carry the `interrupted` key — only the ESC path appends the `<error>` tag and sets `is_error=True`. Pinned by 7 tests in `tests/test_bash_timeout_vs_esc.py` covering the supervisor return values, the `_bash_call` ToolResult shapes, and the `_bash_map_result_to_api` API-block emissions for both paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
Summary
ESC during a Bash command or model stream had to wait the full Bash timeout (10–60s) or the rest of a turn. Three stacked gaps:
_call_toolran sync tools inline (tool_execution.py:441). While Bash blocked, the ESC handler couldn't fireabort_controller.abort()at all._bash_callusedsubprocess.run(timeout=...)with no PID and noabort_signalreference (bash_tool.py:184). Even if abort fired, nothing killed the subprocess._run_model_turndidn't take an abort signal;streaming_querypolled only at the outer while-loop boundary (streaming.py:89). Long responses emitted every chunk before noticing the abort.Fix
Mirrors
typescript/src/utils/ShellCommand.ts(tree-kill on abort listener) + per-chunk signal checks fromtypescript/src/services/api/claude.ts:src/services/tool_execution/tool_execution.pyasyncio.to_threadso the event loop stays responsivesrc/tool_system/tools/bash/bash_tool.pysubprocess.runforPopen(start_new_session=True)+ 50ms abort/timeout poll loop +os.killpg(SIGTERM)→ 2s grace → SIGKILLsrc/query/streaming.pyabort_signalinto_run_model_turn; check.abortedper chunk fromcall_model; re-check after inner loop so aborted-mid-turn yieldsabortednotturn_completeSync entry to
_bash_callis preserved so existing direct callers inskill.pyand tests work unchanged.Test plan
tests/test_tool_system_tools.py70/70,tests/test_skills_shell_exec.py13/13 (Bash sync entry intact)tests/test_streaming_query_loop.py10/10,tests/test_query_loop.py5/5tests/test_streaming_executor*.py+tests/test_stream_watchdog.py29/29tests/test_tool_execution_integration.py+tests/test_tool_orchestration.py+tests/test_orchestrator_concurrency.py+tests/parity/test_tool_execution_order.py41/41sleep 30aborted viaabort_controller: 227ms end-to-end through_call_tool → to_thread → Popen → killpg(was ~30s)abortedevent; no furthertext/turn_completeexit 42)Out of scope
STREAM_IDLE_TIMEOUT_MS = 90_000). Separate concern, separate PR.abort_signalintocall_modelitself so we can tear down the HTTP stream — current per-chunk check is sufficient for the dominant case.🤖 Generated with Claude Code