Skip to content

Commit 1ad4f14

Browse files
committed
fix(chat): a teardown invalidates in-flight work instead of racing it
Review on #83, and the first point is sharp: the teardown was losing a race it did not know it was in. Closing the tab mid-stream calls abort(), and the abort lands in handleSend's catch AFTER resetConversationState has already cleared `conversation` — where the abort branch pushed the partial reply straight back in. The result was a dangling assistant turn with no user turn in front of it, shipped to the model on the next send: exactly the leak the teardown exists to prevent, reintroduced by the teardown's own abort(). agentFlow's finally was worse than reported. runAgent holds `agentMessages` BY REFERENCE, so a teardown that rebinds the global leaves the run pushing into an orphaned array — and then `agentMessages.slice(sessTurnStart)` slices the NEW empty one with an index into the old, recording an empty turn against the wrong session. `abort = null` and `currentCheckpoint = null` are worse still: those clobber whatever turn came next, leaving a fresh run unstoppable and its checkpoint unclosed. Fixed with an epoch. resetConversationState bumps `conversationEpoch` FIRST — before clearing anything, so an abort landing mid-teardown already reads as stale — and each turn captures the epoch before installing its AbortController, then checks it before writing anything back. It also now clears pending approvals/questions, which the guarded finally no longer does on a torn-down turn. Second point: the chatStartLocation docstring still called `secondarySidebar` a supported surface ("kept because the sidebar is the right answer when…") while the code below mapped it away as legacy. In-code documentation sitting directly on top of the change is the worst place to leave a contradiction. Guards, each bypass-verified by reverting the fix: - the teardown no longer invalidating in-flight work - the epoch bumped AFTER clearing, which leaves the race window open - handleSend pushing back without checking; the finally nulling a new turn's controller - agentFlow's finally clobbering the next turn - the epoch captured after the controller is installed - the docstring dropping the legacy marker Caught while wiring it: handleSend referenced `epoch` without capturing it — a ReferenceError at runtime that `node --check` cannot see, because it only checks syntax. 23 tests in chatSurface, 34 suites green.
1 parent 6fd14c2 commit 1ad4f14

2 files changed

Lines changed: 85 additions & 5 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ let pendingTranscriptReplay = '';
6363
let sessionsWebview; // the Sessions sidebar webview (for pushing list refreshes after a History action)
6464
/** @type {{role:string,content:string}[]} */
6565
let conversation = [];
66+
// Bumped by every teardown. A turn captures it when it starts and checks it before writing anything
67+
// back, so work still unwinding after the conversation was torn down cannot repopulate the state the
68+
// teardown just cleared — nor clobber the turn that replaced it.
69+
let conversationEpoch = 0;
6670
/** @type {string | null} */
6771
let pendingContext = null;
6872
/** Files pinned as chat context (whole codebase-wide context). @type {{id:string,uri:vscode.Uri,name:string,rel:string}[]} */
@@ -1123,6 +1127,12 @@ function sealLiveSession(why) {
11231127
* to re-render; the close path is tearing one down.
11241128
*/
11251129
function resetConversationState() {
1130+
// FIRST: anything already in flight is now stale, and must not write back. Without this the
1131+
// teardown loses a race it does not know it is in — see handleSend/agentFlow, both of which mutate
1132+
// this state from a catch/finally that runs long after abort() returns.
1133+
conversationEpoch++;
1134+
clearApprovals();
1135+
clearQuestions();
11261136
conversation = [];
11271137
agentMessages = [];
11281138
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
@@ -1594,6 +1604,7 @@ async function agentFlow(text) {
15941604
if (!req.ok) { post({ type: 'agentError', message: providerErrorMessage(req) }); post({ type: 'agentDone', reason: 'error' }); return; }
15951605

15961606
post({ type: 'agentStart' });
1607+
const epoch = conversationEpoch; // this turn belongs to the conversation as it is RIGHT NOW
15971608
abort = new AbortController();
15981609
repairAgentMemory();
15991610
// Open a workspace checkpoint for this turn (before the goal is pushed) so the user can roll back here.
@@ -1689,6 +1700,12 @@ async function agentFlow(text) {
16891700
signal: abort.signal
16901701
});
16911702
} finally {
1703+
// Everything below writes to state a teardown may already have replaced. runAgent holds
1704+
// `agentMessages` BY REFERENCE, so a teardown that rebinds the global leaves this run pushing
1705+
// into an orphaned array — and then `agentMessages.slice(sessTurnStart)` would slice the NEW,
1706+
// empty one with an index into the old, recording an empty turn against the wrong session.
1707+
// `abort` and `currentCheckpoint` are worse: they would clobber whatever turn came next.
1708+
if (epoch !== conversationEpoch) { return; }
16921709
clearApprovals();
16931710
clearQuestions();
16941711
abort = null;
@@ -1736,6 +1753,7 @@ async function handleSend(text) {
17361753
post({ type: 'clearContext' });
17371754
post({ type: 'assistantStart' });
17381755

1756+
const epoch = conversationEpoch; // this turn belongs to the conversation as it is RIGHT NOW
17391757
abort = new AbortController();
17401758
let assistant = '';
17411759
const onDelta = (d) => { assistant += d; post({ type: 'assistantDelta', text: d }); };
@@ -1764,6 +1782,11 @@ async function handleSend(text) {
17641782
conversation.push({ role: 'assistant', content: assistant });
17651783
post({ type: 'assistantDone' });
17661784
} catch (e) {
1785+
// Closing the tab mid-stream aborts the request, which lands HERE — after resetConversationState
1786+
// has already cleared `conversation`. Pushing the partial reply back in would leave a dangling
1787+
// assistant turn with no user turn in front of it, and the next send would ship it to the model:
1788+
// exactly the leak the teardown exists to prevent, reintroduced by the teardown's own abort.
1789+
if (epoch !== conversationEpoch) { return; }
17671790
if (abort && abort.signal.aborted) {
17681791
if (assistant) { conversation.push({ role: 'assistant', content: assistant }); }
17691792
post({ type: 'assistantDone' });
@@ -1772,7 +1795,9 @@ async function handleSend(text) {
17721795
post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code });
17731796
}
17741797
} finally {
1775-
abort = null;
1798+
// Only if this turn still owns it. A new turn may already have installed its own controller, and
1799+
// nulling that one would leave it unstoppable.
1800+
if (epoch === conversationEpoch) { abort = null; }
17761801
}
17771802
}
17781803

@@ -2369,10 +2394,12 @@ async function openChatInEditor(opts) {
23692394
/**
23702395
* Where the chat opens when the window does.
23712396
*
2372-
* The default is the EDITOR: the chat is the thing most sessions are actually about, and a centred
2373-
* column is where the reference puts it. `secondarySidebar` is the old behaviour, kept because the
2374-
* sidebar is the right answer when you want the chat beside code rather than instead of it, and
2375-
* `none` is the honest opt-out for anyone who would rather open it themselves.
2397+
* Two supported values. `editor` (the default) opens the chat as a centred editor tab — the only
2398+
* surface it has; `none` is the honest opt-out for anyone who would rather open it themselves.
2399+
*
2400+
* `secondarySidebar` is LEGACY. It was valid until the chat became editor-only and is still sitting in
2401+
* real settings.json files, so it is mapped to `editor` rather than left to the unknown-value path —
2402+
* same result, but the debug log then names the surface we actually opened.
23762403
*
23772404
* Unknown values fall back to the default rather than throwing: this is read at startup, and a typo
23782405
* in settings.json should not be able to leave a window with no chat and no explanation.

‎extensions/levelcode-ai/test/chatSurface.test.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,4 +401,57 @@ test('START: a legacy secondarySidebar setting maps to the editor, and says so',
401401
assert.strictEqual(run('nonsense'), 'editor', 'an unknown value must fall back to the default');
402402
});
403403

404+
test('CLOSE: work still unwinding cannot repopulate the state the teardown just cleared', () => {
405+
// Review found the teardown losing a race it did not know it was in. Closing mid-stream aborts the
406+
// request, and the abort lands in handleSend's catch AFTER resetConversationState has cleared
407+
// `conversation` — where it pushed the partial reply straight back in. The result was a dangling
408+
// assistant turn with no user turn in front of it, shipped to the model on the next send: the exact
409+
// leak the teardown exists to prevent, reintroduced by the teardown's own abort().
410+
//
411+
// agentFlow's finally was worse. runAgent holds `agentMessages` BY REFERENCE, so a teardown that
412+
// rebinds the global leaves the run pushing into an orphaned array — and then
413+
// `agentMessages.slice(sessTurnStart)` slices the NEW empty one with an index into the old.
414+
// `abort = null` and `currentCheckpoint = null` would clobber whatever turn came next.
415+
const reset = fnBody(ext, 'resetConversationState');
416+
assert.match(reset, /conversationEpoch\+\+/, 'the teardown does not invalidate in-flight work');
417+
418+
// FIRST, before anything is cleared: an abort landing mid-teardown must already read as stale.
419+
assert.ok(reset.indexOf('conversationEpoch++') < reset.indexOf('conversation = []'),
420+
'the epoch must be bumped before the state is cleared, or the race window survives the fix');
421+
422+
for (const fn of ['handleSend', 'agentFlow']) {
423+
const body = fnBody(ext, fn);
424+
const captured = body.indexOf('const epoch = conversationEpoch');
425+
assert.ok(captured >= 0, fn + ' never captures the epoch — it cannot tell if its turn is still current');
426+
assert.ok(captured < body.indexOf('abort = new AbortController()'),
427+
fn + ' captures the epoch after installing its controller; capture it before the turn can be torn down');
428+
assert.match(body, /epoch !== conversationEpoch/, fn + ' writes back without checking it is still current');
429+
}
430+
431+
// The guard has to come before the first write in the block it protects, or it guards nothing.
432+
const send = fnBody(ext, 'handleSend');
433+
const tail = send.slice(send.lastIndexOf('} catch (e) {'));
434+
assert.ok(tail.indexOf('epoch !== conversationEpoch') < tail.indexOf("conversation.push"),
435+
'handleSend pushes the partial reply before checking the turn is still current');
436+
assert.match(tail, /if \(epoch === conversationEpoch\) \{ abort = null; \}/,
437+
'the finally nulls `abort` unconditionally — that clobbers the controller of the turn that replaced this one');
438+
439+
const agent = fnBody(ext, 'agentFlow');
440+
const afin = agent.slice(agent.lastIndexOf('} finally {'));
441+
assert.ok(afin.indexOf('epoch !== conversationEpoch') < afin.indexOf('abort = null'),
442+
'agentFlow clobbers abort/currentCheckpoint/recordTurn before checking the turn is still current');
443+
});
444+
445+
test('START: the docstring describes the values that actually exist', () => {
446+
// It still called secondarySidebar a supported surface ("kept because the sidebar is the right
447+
// answer when…") while the code below mapped it away as legacy. In-code documentation sitting
448+
// directly on top of the change is the worst place to leave a contradiction.
449+
const at = ext.indexOf('Where the chat opens when the window does');
450+
assert.ok(at > 0, 'the chatStartLocation docstring is gone');
451+
const doc = ext.slice(at, ext.indexOf('function chatStartLocation', at));
452+
assert.match(doc, /LEGACY/, 'the docstring does not mark secondarySidebar as legacy');
453+
assert.ok(!/kept because/.test(doc), 'the docstring still describes secondarySidebar as a supported surface');
454+
assert.match(doc, /`editor`[\s\S]*`none`/, 'the docstring should name the two values that are actually supported');
455+
});
456+
404457
console.log('\nchatSurface: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)