don't let a superseded turn finish somebody else's session

A replaced turn is killed but dies asynchronously, so its proc.exited fired long after
the replacement was registered under the same sessionKey — and then ran the whole
completion path against it: emitted "OpenCode exited with code 143", which the sidecar
commits to chat_session_events so a false failure became permanent history, then deleted
the replacement from `running`. That blinded the new Live panel, made the stop button a
no-op and orphaned a process nothing could reach.

Mark the handle before killing it, retire it silently, and identity-check the delete —
a superseded turn does not own that key any more.

Two leaks in the same family, found while fixing it. An early return would not have been
enough: both watchdogs call finish, so the armed 10-minute hardTimer would have fired an
error at whichever turn held the key by then. And handleLine had no `done` guard, so
stdout still draining from the killed process was emitted under the replacement key.

Reproduced before fixing. The lifecycle tests need no real opencode — RunnerConfig.bin
takes a shell script that sleeps. The control test pins that an ordinary non-zero exit
still reports an error, so the guard cannot overreach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:50:46 +01:00
co-authored by Claude Opus 5
parent 3216040d2f
commit c252f24f1d
2 changed files with 119 additions and 8 deletions
+75 -2
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'bun:test';
import { mapRunLine } from './runner';
import { afterAll, describe, expect, it } from 'bun:test';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { RunnerMessage } from './runner';
import { killOpenCodeTurn, listRunningOpenCodeTurns, mapRunLine, runOpenCodeTurn } from './runner';
// The first tests on the OpenCode path, which had none.
//
@@ -109,3 +113,72 @@ describe('mapRunLine — session id', () => {
expect(mapRunLine(JSON.stringify({ type: 'text', part: { text: 'hi' } }))?.sessionId).toBeUndefined();
});
});
// ── The turn lifecycle, which `mapRunLine`'s extraction deliberately left with the caller ──
//
// Emitting the session id once and accumulating cost were kept out of the pure mapper because they span
// lines. That was right, and it left them as the only untested logic on this path — which is exactly
// where the supersede defect lived: a replaced turn's `proc.exited` fired late and ran `finish()` against
// the turn that had replaced it.
//
// No real `opencode` needed. `RunnerConfig.bin` is the only injection point, so a shell script that
// outlives the test stands in for a turn that is still generating.
const stubDir = mkdtempSync(join(tmpdir(), 'oc-runner-test-'));
/** Stands in for a turn that is still generating. */
const STUB_BIN = join(stubDir, 'fake-opencode');
writeFileSync(STUB_BIN, '#!/bin/sh\nsleep 30\n');
chmodSync(STUB_BIN, 0o755);
/** Stands in for a turn that fails on its own. A script, not `/bin/false` — that is `/usr/bin/false` on macOS. */
const FAILING_BIN = join(stubDir, 'failing-opencode');
writeFileSync(FAILING_BIN, '#!/bin/sh\necho "boom" >&2\nexit 1\n');
chmodSync(FAILING_BIN, 0o755);
const CONFIG = { bin: STUB_BIN, fallbackCwd: stubDir };
afterAll(() => {
rmSync(stubDir, { recursive: true, force: true });
});
describe('runOpenCodeTurn — a second turn on a live session', () => {
it('lets the replacement keep the session: no error, still listed, still killable', async () => {
const sessionKey = 'sess-supersede';
const messages: RunnerMessage[] = [];
const emit = (msg: RunnerMessage) => messages.push(msg);
runOpenCodeTurn({ sessionKey, prompt: 'first', cwd: stubDir }, CONFIG, emit);
runOpenCodeTurn({ sessionKey, prompt: 'second', cwd: stubDir }, CONFIG, emit);
// Let the superseded child actually die. Its exit handler is what used to reach across.
await Bun.sleep(750);
// 1. Nothing is emitted for a turn the system replaced on purpose. This one mattered most: the emit
// is committed to chat_session_events by the sidecar, so a false "OpenCode exited" became history.
expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toEqual([]);
// 2. The replacement is still enumerable — the Live panel's whole purpose.
expect(listRunningOpenCodeTurns()).toContainEqual({ sessionKey });
// 3. And still reachable by the stop button, rather than orphaned with no handle.
killOpenCodeTurn(sessionKey);
await Bun.sleep(250);
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(true);
});
it('still reports a turn that dies on its own, rather than swallowing every exit', async () => {
// The guard must not overreach: an ordinary failure is still an error the user needs to see.
const sessionKey = 'sess-solo';
const messages: RunnerMessage[] = [];
runOpenCodeTurn({ sessionKey, prompt: 'only', cwd: stubDir }, { ...CONFIG, bin: FAILING_BIN }, (m) =>
messages.push(m),
);
await Bun.sleep(750);
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error')).toBe(true);
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
});
});
+44 -6
View File
@@ -30,7 +30,21 @@ export type RunnerMessage =
type Emit = (msg: RunnerMessage) => void;
type RunHandle = { proc: Subprocess; killedByUser: boolean };
type RunHandle = {
proc: Subprocess;
killedByUser: boolean;
/**
* Set when a newer turn has taken this sessionKey over.
*
* A killed process dies asynchronously, so a replaced turn's `proc.exited` fires LONG after its
* replacement is already running and registered under the same key. Without this flag that late
* handler ran the full completion path against the wrong turn: it emitted `OpenCode exited with code
* 143` — which the sidecar commits to `chat_session_events`, so a false failure became permanent
* history — and then deleted its replacement from `running`, which blinded the Live panel, made the
* stop button a no-op, and orphaned a process nothing could reach.
*/
superseded: boolean;
};
// One turn per sessionKey; a new turn supersedes any stale process for that key.
const running = new Map<string, RunHandle>();
@@ -55,9 +69,11 @@ type RunEvent = { type?: string; sessionID?: string; part?: RunPart };
export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, emit: Emit): void {
const { sessionKey } = params;
// Supersede any lingering turn for this session.
// Supersede any lingering turn for this session. Mark it BEFORE killing: the flag is what tells its
// own exit handler that this death was intentional and belongs to nobody.
const stale = running.get(sessionKey);
if (stale) {
stale.superseded = true;
try {
stale.proc.kill();
} catch {
@@ -81,7 +97,7 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
stderr: 'pipe',
});
const handle: RunHandle = { proc, killedByUser: false };
const handle: RunHandle = { proc, killedByUser: false, superseded: false };
running.set(sessionKey, handle);
let done = false;
@@ -92,15 +108,27 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
const emitEvent = (event: ChatEvent) => emit({ type: 'opencode:event', sessionKey, event });
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (event: ChatEvent) => {
/**
* Retire this turn: stop its watchdogs, release its slot, and optionally say why it ended.
*
* The delete is identity-checked because `sessionKey` is not this turn's to own once it has been
* superseded — the map may already hold a live replacement under that key, and deleting by name alone
* removed it. `null` retires silently, which is what a superseded turn needs: it must still clear its
* timers (an armed 10-minute `hardTimer` would otherwise fire an error at whichever turn holds the key
* by then, reproducing the same cross-talk on a delay) while emitting nothing at all.
*/
const settle = (event: ChatEvent | null) => {
if (done) return;
done = true;
clearTimeout(hardTimer);
if (inactivityTimer) clearTimeout(inactivityTimer);
running.delete(sessionKey);
emitEvent(event);
if (running.get(sessionKey) === handle) running.delete(sessionKey);
if (event) emitEvent(event);
};
const finish = (event: ChatEvent) => settle(event);
// ── Watchdogs ──
const hardTimer = setTimeout(() => {
try {
@@ -163,6 +191,11 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
})();
function handleLine(line: string): void {
// A retired turn says nothing more. Stdout is drained asynchronously, so a killed process can still
// have buffered lines in flight — and for a superseded turn those would be emitted under a
// sessionKey that now belongs to its replacement, interleaving one turn's output into another's.
if (done) return;
const mapped = mapRunLine(line);
if (!mapped) return;
@@ -184,6 +217,11 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
// ── Completion: process exit is the authoritative turn-done signal ──
void proc.exited.then((code) => {
if (done) return;
// Replaced on purpose: not a result, not an error, and not this turn's session any more.
if (handle.superseded) {
settle(null);
return;
}
if (handle.killedByUser) {
finish({ type: 'stopped' });
return;