Skip to content

fix(core): drop late tool calls after SIGINT cancellation (#28091)#28096

Open
KittiphonKamnuan wants to merge 2 commits into
google-gemini:mainfrom
KittiphonKamnuan:fix/sigint-late-tool-side-effect
Open

fix(core): drop late tool calls after SIGINT cancellation (#28091)#28096
KittiphonKamnuan wants to merge 2 commits into
google-gemini:mainfrom
KittiphonKamnuan:fix/sigint-late-tool-side-effect

Conversation

@KittiphonKamnuan

Copy link
Copy Markdown

Summary

Closes #28091.

A SIGINT delivered after the stream consumer has already started a turn can still result in a delayed provider tool-call chunk being executed locally — the side effect runs and the tool result is submitted back to the model after the user has cancelled. The standalone reproducer in the issue confirms this on 0.47.0.

Root cause

The abort signal is only checked at the top of each `for await` iteration on the model stream (`Turn.run` in `packages/core/src/core/turn.ts`). A chunk that arrives after cancellation but in the same iteration as the abort still:

  1. materializes `functionCalls` from the response (`turn.ts` ~line 369),
  2. yields `ToolCallRequest` events to the non-interactive CLI driver,
  3. which collects them into `toolCallRequests` and unconditionally calls `scheduler.schedule(...)`,
  4. and the scheduler's `_startBatch` validates + enqueues the call before `_processNextItem`'s own abort check kicks in — at which point the tool runner has already started the side effect.

Fix — defense in depth at three layers

  1. `Turn.run` (`packages/core/src/core/turn.ts`)
    Re-check `signal.aborted` between processing text/thought parts and yielding `ToolCallRequest` events. If the user cancelled mid-chunk, yield `UserCancelled` and stop instead of forwarding the tool call.

  2. Non-interactive CLI driver (`packages/cli/src/nonInteractiveCli.ts`)
    Re-check `signal.aborted` after the stream loop exits and before calling `scheduler.schedule(...)`, so a late tool call that survived the stream layer is not handed to the scheduler.

  3. Scheduler (`packages/core/src/scheduler/scheduler.ts`)
    In `_startBatch`, short-circuit via `cancelAllQueued('Operation cancelled')` if the caller's signal is already aborted, before validating + enqueuing. Mirrors the existing pattern in `_processNextItem`; final backstop against late tool calls reaching the executor.

Each layer ships a regression test for the late-after-cancel ordering.

Test plan

  • `vitest run packages/core/src/core/turn.test.ts` — 23/23 passing (1 new)
  • `vitest run packages/core/src/scheduler/scheduler.test.ts` — 44/44 passing (1 new)
  • `vitest run packages/cli/src/nonInteractiveCli.test.ts` — 56/56 passing
  • `eslint` + `tsc --noEmit` — clean on changed files
  • Standalone Docker reproducer from the issue — happy to run if a maintainer wants confirmation, but the code paths in 1/2/3 above are exactly the ones the reproducer's assertions key off of.

Files changed

  • `packages/core/src/core/turn.ts`
  • `packages/core/src/core/turn.test.ts`
  • `packages/cli/src/nonInteractiveCli.ts`
  • `packages/core/src/scheduler/scheduler.ts`
  • `packages/core/src/scheduler/scheduler.test.ts`

Closes google-gemini#28091.

A SIGINT delivered after the stream consumer has already started a turn
could still result in a delayed provider tool-call chunk being executed
locally: the side-effect ran, and the result was submitted back to the
model after the user had cancelled.

The root cause is that the abort signal was only checked at the top of
each `for await` iteration on the model stream. A chunk that arrived
after cancellation but in the same iteration as the abort would still
materialize tool calls, push them onto the scheduler, and run.

Defense-in-depth fix at three layers:

1. `Turn.run` (`packages/core/src/core/turn.ts`):
   re-check `signal.aborted` between processing text/thought parts and
   yielding `ToolCallRequest` events. If the user cancelled mid-chunk,
   yield `UserCancelled` and stop instead of forwarding the tool call.

2. Non-interactive CLI driver (`packages/cli/src/nonInteractiveCli.ts`):
   re-check `signal.aborted` after the stream loop exits and before
   calling `scheduler.schedule(...)`, so a late tool call that survived
   the stream layer is not handed to the scheduler.

3. Scheduler (`packages/core/src/scheduler/scheduler.ts`):
   in `_startBatch`, short-circuit via `cancelAllQueued('Operation cancelled')`
   if the caller's signal is already aborted, before validating + enqueuing.
   This mirrors the existing pattern in `_processNextItem` and is the
   final backstop against late tool calls reaching the executor.

Each layer has a regression test for the late-after-cancel ordering.
@KittiphonKamnuan KittiphonKamnuan requested a review from a team as a code owner June 22, 2026 16:25
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a race condition where tool calls could be executed locally even after a user has issued a SIGINT cancellation. By implementing a defense-in-depth strategy, the changes ensure that the abort signal is respected at the stream processing, CLI driver, and scheduler layers, preventing late-arriving tool call chunks from triggering side effects after the user has terminated the operation.

Highlights

  • Stream Layer Guard: Added an abort signal check in Turn.run to prevent tool calls from being processed if the user has already cancelled.
  • CLI Driver Protection: Updated the non-interactive CLI driver to verify the abort signal before passing tool requests to the scheduler.
  • Scheduler Short-circuit: Implemented a pre-validation check in the scheduler to immediately cancel queued operations if the signal is already aborted.
  • Regression Testing: Added new test cases across the core and CLI packages to ensure late-arriving tool calls are correctly dropped after cancellation.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions Bot added the size/m A medium sized PR label Jun 22, 2026
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

📊 PR Size: size/M

  • Lines changed: 129
  • Additions: +120
  • Deletions: -9
  • Files changed: 5

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces guards to check for aborted signals before scheduling or executing tool calls, preventing late-arriving side effects from running after user cancellation. It also adds corresponding regression tests. The review feedback highlights a critical issue in the scheduler's early return where bypassing the request queue processing can cause subsequent queued tool call batches to hang indefinitely, and suggests invoking _processNextInRequestQueue() before returning.

Comment on lines +315 to +319
if (signal.aborted) {
this.state.cancelAllQueued('Operation cancelled');
this.isProcessing = false;
return this.state.completedBatch;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Returning early when signal.aborted is true bypasses the finally block of _startBatch. This prevents this._processNextInRequestQueue() from being called, which will cause any subsequent or concurrent tool call batches queued in this.requestQueue to hang indefinitely and never resolve or reject. To prevent this promise/queue leak, we must ensure this._processNextInRequestQueue() is called before returning early.

Suggested change
if (signal.aborted) {
this.state.cancelAllQueued('Operation cancelled');
this.isProcessing = false;
return this.state.completedBatch;
}
if (signal.aborted) {
this.state.cancelAllQueued('Operation cancelled');
this.isProcessing = false;
this._processNextInRequestQueue();
return this.state.completedBatch;
}
References
  1. Asynchronous operations that can be cancelled by the user should accept and propagate an AbortSignal to ensure cancellability and prevent dangling processes or network requests.

@gemini-cli gemini-cli Bot added the area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality label Jun 22, 2026
Follow-up to gemini-code-assist review on google-gemini#28096. The previous fix
short-circuited `_startBatch` with an early `return` *outside* the
`try { ... } finally { ... }`. That bypassed the `finally` block, so
`_processNextInRequestQueue()` never ran when the signal was already
aborted — any concurrent batches queued in `requestQueue` would hang
indefinitely instead of being drained / rejected.

Move the abort guard inside the `try` so the existing `finally`
(`isProcessing = false`, `clearBatch()`, `_processNextInRequestQueue()`)
runs on the early-return path too.

Add a regression test that schedules a second batch after the aborted
one to confirm the scheduler still makes progress.
@KittiphonKamnuan

Copy link
Copy Markdown
Author

Thanks for catching the queue leak, @gemini-code-assist. Fixed in e0efeb8 by moving the abort guard inside the try { ... } finally { ... } block instead of returning before the finally runs.

This keeps the existing cleanup pattern intact:

  • isProcessing = false
  • state.clearBatch()
  • _processNextInRequestQueue() ← was being skipped before, now runs on the aborted path too

Slightly different from the suggested patch (which duplicated the cleanup into the early-return branch) — putting the guard inside try means the cleanup lives in exactly one place, which I think is easier to maintain.

Added a regression test that schedules a second batch after the aborted one to confirm the scheduler still drains the queue and the follow-up call resolves. All 68 scheduler + turn tests pass, lint and tsc clean.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

That is a solid approach. Moving the abort guard inside the try block while relying on the finally block for state cleanup is the correct pattern here. It ensures that isProcessing is correctly reset and the request queue is drained even when an operation is cancelled, preventing the scheduler from entering a hung state. This effectively maintains the integrity of the scheduler's lifecycle across both successful and aborted paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality size/m A medium sized PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gemini CLI executes a tool side effect after SIGINT cancellation

1 participant