Skip to content

fix(cli-tools): forward abortSignal to execAsync so a fired timeout actually kills the command - #681

Open
canblmz1 wants to merge 1 commit into
op7418:mainfrom
canblmz1:fix/cli-tools-abort-signal-not-forwarded
Open

fix(cli-tools): forward abortSignal to execAsync so a fired timeout actually kills the command#681
canblmz1 wants to merge 1 commit into
op7418:mainfrom
canblmz1:fix/cli-tools-abort-signal-not-forwarded

Conversation

@canblmz1

@canblmz1canblmz1 commented Aug 28, 2026

Copy link
Copy Markdown

Problem

codepilot_cli_tools_install and codepilot_cli_tools_update run their real
shell command via execAsync(command, { timeout: 300_000, env }) — no
signal option.

native-timeout.ts already documents why this is a gap, in its own words:

Because ai@7 merely passes the abort signal INTO execute() and still
awaits its promise, firing this budget must not rely on the SDK ending the
stream — the consumer loop must iterate via guardStream (below) to escape
a tool that ignores the signal.

guardStream solves the consumer loop getting stuck. It does nothing for
the real process: execute() never destructured the SDK's abortSignal,
and nothing was ever passed to child_process.exec. So once a
tool-execution (or any other) budget fires — or a run is aborted for any
other reason — the agent loop moves on and reports the run as timed out /
aborted, while the actual install or update command keeps running,
unsupervised, in the background.

Reproduction

Not just theoretical — minimal, deterministic repro:

constcontroller=newAbortController();constp=execAsync('node -e "setTimeout(() => {}, 10000)"',{timeout: 300_000/* no signal */});setTimeout(()=>controller.abort(),400);// 3+ seconds later, the process is still running — abort() had no effect.
constcontroller=newAbortController();constp=execAsync('node -e "setTimeout(() => {}, 10000)"',{timeout: 300_000,signal: controller.signal});setTimeout(()=>controller.abort(),400);// ~50ms later, p rejects with ABORT_ERR — the process is actually killed.

Both cases are in the new test file, as the "aborted" case and an explicit
control case reproducing the current (pre-fix) behavior.

Fix

Destructure abortSignal from execute()'s second parameter (the AI SDK's
ToolCallOptions, confirmed from node_modules/ai's own .d.ts:
abortSignal?: AbortSignal on ToolExecutionOptions) and pass it through as
signal to execAsync() in both tools. child_process.exec has supported
an AbortSignal-based signal option since Node 15/16 — this wires an
already-existing signal through; it adds no new cancellation machinery.

When no timeout budget is configured, native-timeout.ts's own documented
contract is "the controller arms no timers" — so abortSignal is simply
undefined in that case and execAsync behaves exactly as it does today.
Zero behavior change for the default, unconfigured path.

Scope

Only the two execAsync call sites with a real, potentially long-running,
side-effecting command (install, update) are touched — 4 lines, 2 files.
The shorter read-only exec/execFile calls elsewhere in this file
(--version checks, which, brew/npm outdated, all capped 5-30s) are
intentionally left alone to keep this patch minimal and focused on the exact
bug class it fixes.

cli-tools-mcp.ts (the SDK Runtime's separate implementation — the two
files currently maintain independent execAsync call sites, despite this
file's own header comment describing itself as the "single source of
truth") has the same gap but is intentionally not touched here, same
scoping precedent #676 used for Native-Runtime-only changes.

This is unrelated to #676 (deferred-execution-integrity — whether
execute() should run at all based on the surrounding stream's terminal
state). This fixes a different question: once execute() has legitimately
started, does aborting it actually stop the real side effect, or does it
just orphan it in the background. Both can land independently.

Testing

  • src/__tests__/unit/cli-tools-abort-signal.test.ts (new): 2/2 passing —
    node --test src/__tests__/unit/cli-tools-abort-signal.test.ts
    • exec() with a wired signal actually kills the process on abort
    • control case: exec()without a signal (today's behavior) — an
      aborted AbortController has no effect on the running command
  • No existing test imports builtin-tools/cli-tools.ts directly (checked
    before writing this patch), so there's no prior coverage of this file to
    regress.
  • Type verified directly against the installed ai package's .d.ts
    (abortSignal?: AbortSignal on ToolExecutionOptions) rather than
    assumed.
  • npx tsc --noEmit (full project): clean, 0 errors. (This took a long
    first-run pass in my sandbox — took long enough that I initially posted
    this PR before it finished and said so; it has since completed cleanly,
    updating that note now rather than leaving a stale caveat in place.)

No unrelated changes.

…ctually kills the command
Root cause: codepilot_cli_tools_install and codepilot_cli_tools_update call
execAsync(command, { timeout: 300_000, env }) without a `signal` option.
native-timeout.ts already documents why this matters: "ai@7 merely passes
the abort signal INTO execute() and still awaits its promise... the
consumer loop must iterate via guardStream to escape a tool that ignores
the signal." guardStream lets agent-loop.ts's own consuming loop move on
once a budget fires, but neither execute() destructured the SDK's
abortSignal nor passed anything to child_process.exec, so the real shell
command was never actually told to stop. The process keeps running to
completion (or Node's own hardcoded 300s timeout) in the background, after
the run has already been reported as timed out / aborted to the user and
the model.
Confirmed with a minimal reproduction (not just theoretical): calling
execAsync() with a long-running command, aborting an AbortController 400ms
in, with vs without `signal` wired to exec's options —
without signal: process still running 3s+ after abort() fires
with signal: process rejected (ABORT_ERR) within ~50ms of abort() firing
Fix: destructure `abortSignal` from execute()'s second parameter
(AI SDK's ToolCallOptions) in both codepilot_cli_tools_install and
codepilot_cli_tools_update, and pass it as `signal` to their execAsync()
calls. Node's child_process.exec has supported an AbortSignal-based
`signal` option since Node 15/16 — this wires an already-existing signal
through, it does not add new cancellation infrastructure. When no timeout
budget is configured, native-timeout.ts's own contract is that the
controller arms no timers, so abortSignal is simply undefined and exec()
behaves exactly as before — zero behavior change for the default,
unconfigured case.
Scope: only the two execAsync call sites with a real, long-running,
side-effecting shell command (install, update) are touched. The shorter,
read-only exec/execFile calls elsewhere in this file (--version checks,
`which`, `brew/npm outdated` queries, all capped at 5-30s) are left as-is
to keep this patch minimal and focused on the class of bug it fixes: an
externally-visible "timed out" result while a real command is still
running unsupervised.
cli-tools-mcp.ts (the SDK Runtime's separate implementation, per its own
header comment "the pure handler functions extracted from cli-tools-mcp.ts"
notwithstanding — the two files currently maintain independent execAsync
call sites) has the same gap but is intentionally not touched here, same
scoping precedent as PR op7418#676's Native-Runtime-only scope.
This is unrelated to prefix-safe-json / PR op7418#676's deferred-execution-
integrity fix (whether execute() should run at all based on the
surrounding stream's terminal state) — this fixes a different question
(once execute() has legitimately started, does aborting actually stop the
real side effect, or does it just orphan it in the background).
Tests: src/__tests__/unit/cli-tools-abort-signal.test.ts, 2 new tests —
one proving exec() honors a wired signal (kills the process on abort),
one control case reproducing the exact pre-fix behavior (an aborted
AbortController with no `signal` passed to exec() has no effect on the
running command). Both pass: `node --test src/__tests__/unit/cli-tools-abort-signal.test.ts`.
No existing test imports builtin-tools/cli-tools.ts directly (confirmed by
searching src/__tests__/unit for references before writing this patch),
so there is no existing coverage of this file to regress.
@github-actionsgithub-actionsBot added the area:tests PR/issue 影响面: tests label Aug 28, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:testsPR/issue 影响面: tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@canblmz1