Dev.to AI 🤖 Ai 👁 0 📖 2 min read

The Cancel Button Lied: Fixing a Leaked Request in Google's gemini-cli

Here is a bug shape every developer meets eventually: the user presses cancel, the UI agrees, and something keeps running anyway. I found one in Google's gemini-cli, in multi-turn chat, and the fix was merged as fix(core

Here is a bug shape every developer meets eventually: the user presses cancel, the UI agrees, and something keeps running anyway. I found one in Google's gemini-cli, in multi-turn chat, and the fix was merged as fix(core): thread AbortSignal to chat compression requests.

The bug

In a multi-turn chat, cancelling a turn stopped the visible stream. But a chat-history compression request — started before the turn, to keep the context window manageable — never received the turn's AbortSignal. The turn's cancellation stopped what the user could see and left a model request running in the background: a wasted call, and one that could race with the next prompt.

This is the shape cancellation bugs take. The thing you are watching gets cancelled. The thing that started before the thing you are watching does not.

How I found it

I did not read the whole codebase. I followed one value end to end: the AbortSignal created for the turn. From turn creation, down through the execution layers, to every call that should have received it. The signal reached the streaming request and stopped short of the compression request. One layer was creating work without passing the signal along.

That is the technique that works in an unfamiliar codebase: pick the value, follow it, find where it gets dropped. Reading every file is a slower way to find the same gap, and in a repo this size it is not a realistic way at all.

The fix

Thread the signal through the layers that dropped it, so the compression request is created with the turn's AbortSignal and dies with the turn. Small, scoped, and verifiable: cancel a turn mid-compression and the request actually stops.

The general pattern, in the shape I now check for in any async code:

async function runTurn(signal: AbortSignal) {
  // Every async unit of work the turn starts gets the same signal —
  // including work started BEFORE the visible part of the turn.
  const compressed = compressHistory(signal);
  const stream = streamModelResponse(signal);
  await Promise.all([compressed, stream]);
}

Two rules fall out of it:

  1. Pass the signal at creation, not at cancellation. If a call is created without a signal, no amount of cancelling later will reach it. Audit the creation sites, not the cancel handler.
  2. Link child controllers to their parent. When a sub-task needs its own AbortController, wire the parent's abort event to abort the child. An unlinked child controller is a leak waiting for a user to press cancel.

What I took away

Cancellation bugs hide in the calls before the one you are watching. The reproduction was the key to getting the fix merged: a concrete sequence — start a turn, trigger compression, cancel, observe the request continuing — plus a diff small enough for a reviewer to say yes to quickly.

If you maintain anything with long-running requests and a cancel path, do this audit once: list every async call a user action starts, and check each one receives the signal. The one that does not is your bug. It is almost always the call that started first.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.