Skip to content

fix: preempt in-flight turn when a new prompt arrives without cancel#2

Merged
william0wang merged 1 commit into
mainfrom
chore/fix-bug
Jul 5, 2026
Merged

fix: preempt in-flight turn when a new prompt arrives without cancel#2
william0wang merged 1 commit into
mainfrom
chore/fix-bug

Conversation

@william0wang

Copy link
Copy Markdown
Owner

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 packageManager field 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/prompt request.

Root cause: prompt() had no check for an in-flight turn on the same session. It went straight to session/send, which the backend rejected (code 1308, "prompt is running"). The error path then called ensureTurnStopped, 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 preemptInFlightTurn step at the prompt() entry (after slash interception, before registering the pending turn):

  1. Scan pendingTurns for an in-flight turn matching the same zcodeSid (excluding self).
  2. If found, mark oldTurn.cancelled = true (same propagation mechanism as cancel()).
  3. Call ensureTurnStopped to stop the backend turn and wait for the prompt-lock to release.
  4. Poll until the old PendingTurn is removed from the map — this is the synchronization point that guarantees the old turn's finally block has run (listener unregistered), so the new turn can safely subscribe without the listener being overwritten (registerEventListener uses Map.set).
  5. Best-effort, never throws: 35s timeout, continues anyway if the old turn is stuck (in which case session/send will hit the lock and take the existing error path).

Why wait for the map entry (not just the lock)

Registering a second EventStreamListener overwrites the first (Map.set in client.ts). If the new prompt subscribed before the old loop ran its finally block, the old loop would block forever and the new loop would steal its events. The pendingTurns.delete in the old loop's finally is the synchronization point.

CI fix

The previous PR removed the packageManager field from package.json. CI's pnpm/action-setup@v4 read the version from that field, so builds broke with No pnpm version is specified.

Fix: pin version: 10 (a major-version

@william0wang william0wang merged commit ecf30a4 into main Jul 5, 2026
1 check passed
@william0wang william0wang deleted the chore/fix-bug branch July 5, 2026 07:54

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

Copy link
Copy Markdown

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 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.

Comment thread src/handlers/session.ts
Comment on lines +473 to +520
/**
* 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`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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:

  1. Prompt B waits for Turn A to exit, then registers itself.
  2. 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);
    }
  }
}

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant