fix: preempt in-flight turn when a new prompt arrives without cancel#2
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a preemption mechanism in src/handlers/session.ts to stop and wait for any in-flight turn to exit before starting a new prompt for the same session. The reviewer identified a potential race condition where multiple concurrent prompts sent in rapid succession could bypass this check before registering themselves, and suggested a promise-chaining mechanism using a session locks map to serialize preemption and registration.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| /** | ||
| * If another prompt() is already running for this zcodeSid, treat the new | ||
| * prompt as an implicit cancel: stop the in-flight turn and wait for it to | ||
| * fully exit (lock released + listener unregistered + pendingTurns cleaned) | ||
| * before the new prompt subscribes and sends. | ||
| * | ||
| * Why wait for the map entry to disappear (not just the lock): registering | ||
| * a second EventStreamListener overwrites the first (Map.set in client.ts), | ||
| * so the old turn loop must have run its finally block before we subscribe. | ||
| * The map cleanup in that finally block is the synchronization point. | ||
| * | ||
| * Best-effort: never throws. On timeout, continues anyway — session/send | ||
| * will then hit the lock and take the existing error path. | ||
| */ | ||
| async function preemptInFlightTurn( | ||
| server: ZcodeAcpServer, | ||
| zcodeSid: string, | ||
| selfRequestId: number, | ||
| ): Promise<void> { | ||
| // Find any in-flight turn for this session that isn't this request. | ||
| let oldRequestId: number | undefined; | ||
| for (const [reqId, turn] of server.pendingTurns) { | ||
| if (turn.zcodeSid === zcodeSid && reqId !== selfRequestId) { | ||
| oldRequestId = reqId; | ||
| turn.cancelled = true; // signal the old turn loop to exit | ||
| break; | ||
| } | ||
| } | ||
| if (oldRequestId === undefined) return; // no in-flight turn, proceed | ||
|
|
||
| log(` [preempt] in-flight turn ${oldRequestId} found, stopping it`); | ||
| // Stop the backend turn and wait for the prompt lock to release. | ||
| await ensureTurnStopped(server, zcodeSid); | ||
|
|
||
| // Wait for the old turn's prompt() to fully exit (its finally block deletes | ||
| // the pendingTurns entry). This is the synchronization point that guarantees | ||
| // its listener is unregistered before we register ours. | ||
| const PREEMPT_TIMEOUT_MS = 35_000; // slightly longer than ensureTurnStopped's 30s | ||
| const t0 = Date.now(); | ||
| while (server.pendingTurns.has(oldRequestId)) { | ||
| if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) { | ||
| log(` [preempt] timed out waiting for old turn ${oldRequestId} to exit`); | ||
| return; // best-effort: continue anyway, session/send may fail | ||
| } | ||
| await sleep(200); | ||
| } | ||
| log(` [preempt] old turn ${oldRequestId} exited, proceeding`); | ||
| } |
There was a problem hiding this comment.
Race Condition with Concurrent Prompts
If multiple new prompts (e.g., Prompt B and Prompt C) are sent in rapid succession while an old turn (Turn A) is still running, both B and C will call preemptInFlightTurn before registering themselves in pendingTurns.
Both will find Turn A, trigger its cancellation, and wait for it to exit. Once Turn A exits, both B and C will simultaneously exit preemptInFlightTurn and register themselves in pendingTurns, leading to concurrent active turns running for the same session.
Solution
We can resolve this race condition by chaining the preemption promises using a module-level sessionLocks map. This ensures that preemption and registration happen sequentially:
- Prompt B waits for Turn A to exit, then registers itself.
- Prompt C waits for Prompt B to exit, cancels Prompt B, and then registers itself once Prompt B exits.
const sessionLocks = new Map<string, Promise<void>>();
/**
* If another prompt() is already running for this zcodeSid, treat the new
* prompt as an implicit cancel: stop the in-flight turn and wait for it to
* fully exit (lock released + listener unregistered + pendingTurns cleaned)
* before the new prompt subscribes and sends.
*
* Why wait for the map entry to disappear (not just the lock): registering
* a second EventStreamListener overwrites the first (Map.set in client.ts),
* so the old turn loop must have run its finally block before we subscribe.
* The map cleanup in that finally block is the synchronization point.
*
* Best-effort: never throws. On timeout, continues anyway — session/send
* will then hit the lock and take the existing error path.
*/
async function preemptInFlightTurn(
server: ZcodeAcpServer,
zcodeSid: string,
selfRequestId: number,
): Promise<void> {
const existingPromise = sessionLocks.get(zcodeSid) || Promise.resolve();
const myPromise = (async () => {
await existingPromise;
// Find any in-flight turn for this session that isn't this request.
let oldRequestId: number | undefined;
for (const [reqId, turn] of server.pendingTurns) {
if (turn.zcodeSid === zcodeSid && reqId !== selfRequestId) {
oldRequestId = reqId;
turn.cancelled = true; // signal the old turn loop to exit
break;
} // one turn per session at a time
}
if (oldRequestId === undefined) return;
log(` [preempt] in-flight turn ${oldRequestId} found, stopping it`);
// Stop the backend turn and wait for the prompt lock to release.
await ensureTurnStopped(server, zcodeSid);
// Wait for the old turn's prompt() to fully exit (its finally block deletes
// the pendingTurns entry). This is the synchronization point that guarantees
// its listener is unregistered before we register ours.
const PREEMPT_TIMEOUT_MS = 35_000; // slightly longer than ensureTurnStopped's 30s
const t0 = Date.now();
while (server.pendingTurns.has(oldRequestId)) {
if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) {
log(` [preempt] timed out waiting for old turn ${oldRequestId} to exit`);
return; // best-effort: continue anyway, session/send may fail
}
await sleep(200);
}
log(` [preempt] old turn ${oldRequestId} exited, proceeding`);
})();
sessionLocks.set(zcodeSid, myPromise);
try {
await myPromise;
} finally {
if (sessionLocks.get(zcodeSid) === myPromise) {
sessionLocks.delete(zcodeSid);
}
}
}
Summary
Fixes a bug where sending a new message while a turn is still running (without pressing stop first) failed with "A prompt is already running", killing the old turn and losing the new message. Also fixes the CI pnpm setup that broke after the
packageManagerfield was removed.Problem
Scenario: A turn is running (prompt-lock held). The user sends a new message directly, without pressing stop. The client issues a new
session/promptrequest.Root cause:
prompt()had no check for an in-flight turn on the same session. It went straight tosession/send, which the backend rejected (code 1308, "prompt is running"). The error path then calledensureTurnStopped, which killed the running turn, and threw — so the old turn was interrupted AND the new message was lost. Worst-case outcome.The ACP protocol does not define "new prompt preempts old prompt" (the defined interrupt primitive is
session/cancel). But a robust server should treat this client behavior as an implicit cancel + new prompt, not reject it.Fix
Add a
preemptInFlightTurnstep at theprompt()entry (after slash interception, before registering the pending turn):pendingTurnsfor an in-flight turn matching the samezcodeSid(excluding self).oldTurn.cancelled = true(same propagation mechanism ascancel()).ensureTurnStoppedto stop the backend turn and wait for the prompt-lock to release.PendingTurnis removed from the map — this is the synchronization point that guarantees the old turn'sfinallyblock has run (listener unregistered), so the new turn can safely subscribe without the listener being overwritten (registerEventListenerusesMap.set).session/sendwill hit the lock and take the existing error path).Why wait for the map entry (not just the lock)
Registering a second
EventStreamListeneroverwrites the first (Map.setinclient.ts). If the new prompt subscribed before the old loop ran itsfinallyblock, the old loop would block forever and the new loop would steal its events. ThependingTurns.deletein the old loop'sfinallyis the synchronization point.CI fix
The previous PR removed the
packageManagerfield frompackage.json. CI'spnpm/action-setup@v4read the version from that field, so builds broke withNo pnpm version is specified.Fix: pin
version: 10(a major-version