Skip to content

Commit 5182a1f

Browse files
committed
fix(cloud): three more review findings from #92 — late answers, a deadline, in-place resets
1. A refresh's answer could be applied to a session it was not about. The request is out for as long as the network takes, and nothing tied its result to the session it started against. A 401 arriving after the user had signed in again deleted the pair that sign-in had just stored and marked it expired. A 200 arriving after a sign-out signed them back in; after a sign-in as someone else it filed the old account's tokens under the new name. Two checks now, because there are two ways for the session to change. A generation counter — bumped by sign-in, sign-out and expiry — covers this window exactly. Comparing the stored refresh token with the one that was sent covers another window, which no counter here can see. And every credential change runs through one queue (withSessionLock), because each is several awaits long: without it a sign-in can land between an expiry's "is this still the dead session?" and its deletes — the reviewer's point that comparing tokens is not enough on its own. The queue is per process; between windows the comparison narrows that gap to milliseconds, it does not close it. A superseded answer changes nothing and reports whether a token is in place to retry with. If there is none and this window still believes it is signed in, another window ended the session first, and this one catches up: the card, and a popover and footer that stop claiming it is live. 2. The refresh had no deadline, and `ready` waits on it before it restores the chat. A host that accepted the connection and then went quiet held the transcript, the config and the review controls behind it. The whole exchange now gets ten seconds: the timer is cleared after the body is read, not at the headers. Running out is "this attempt failed", so the tokens stay. 3. The replay ran only on `ready`, and I was wrong about what sends one. New Chat and a session resumed from History do not reload the document: they post `reset`, which empties the log in place, card included. The previous commit's comment and message said otherwise. A checkpoint restore does the same by dropping every node after the restored turn. All three now replay an unanswered expiry afterwards — under the restored transcript when resuming. Verified: 43 suites, 741 cases, 0 failing. sessionExpiredHost.test.js goes from 25 to 44 cases, and its stand-in stores now answer a turn of the event loop later, as the real ones do: that gap is where two changes interleave, and a stand-in that answered at once would have hidden what the lock is for. Each defect put back fails its own case: every answer applied -> "the new access token survived"; no token comparison -> another window's sign-in; no counter -> a sign-in that brought no refresh token of its own; no lock -> the sign-in started mid-expiry, with the interleaved writes in the message; no signal -> the silent host never finishes; timer cleared at the headers -> the stalled body; timer never cleared -> aborted after the fact; each replay removed, or run before the transcript -> its own case. tsc --checkJs reports nothing new. In headless Chrome against the real chat.html, `reset`, a resume and a checkpoint restore each remove the card, and the replay puts it back at the bottom.
1 parent 9a5931c commit 5182a1f

4 files changed

Lines changed: 460 additions & 61 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 132 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -362,65 +362,144 @@ function isAuthError(e) { return /\bAPI 401\b|\b401\b.*unauthor/i.test(String((e
362362
* 2. It knows the difference between "this attempt failed" and "the session is over". Only an
363363
* explicit 401 from the refresh endpoint is the latter; that ends the session (sessionExpired).
364364
* Offline, a 5xx, a malformed reply: nothing is known yet, so the tokens stay.
365+
*
366+
* And two things the wait in the middle demands. The request is out for as long as the network
367+
* takes, and the rest of the editor does not stand still for it:
368+
*
369+
* 3. Its answer is about the session it STARTED against, and is applied to that one only.
370+
* Meanwhile the user can sign in again, or sign out. A late 401 must not delete the pair a
371+
* sign-in just stored; a late 200 must not sign someone back in, or file one account's tokens
372+
* under another's name. See sameSession().
373+
* 4. It has a deadline, body included. `ready` waits on this before it restores the chat, so a
374+
* host that accepts the connection and then goes quiet would otherwise hold the transcript, the
375+
* config and the review controls behind it. Running out of time is (2)'s "this attempt failed".
376+
*
377+
* So "true" means: a usable access token is in place now — this refresh's, or a newer session's.
365378
*/
366379
async function refreshCloudToken() {
367380
if (!ctx) { return false; }
368381
const endpoint = cloudApiUrl();
369382
if (!/^https:\/\//i.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(endpoint)) { return false; }
370-
const refresh = await ctx.secrets.get(ACCOUNT_REFRESH_KEY);
371-
if (!refresh) { return false; }
383+
// Which session this request is about. Read under the lock, so never halfway through a sign-in.
384+
const was = await withSessionLock(async () => ({ generation: sessionGeneration, refresh: await ctx.secrets.get(ACCOUNT_REFRESH_KEY) }));
385+
if (!was.refresh) { return false; }
372386
let outcome = 'retry';
387+
/** @type {any} */
388+
let data = null;
389+
const ac = new AbortController();
390+
const deadline = setTimeout(() => ac.abort(), session.REFRESH_TIMEOUT_MS);
373391
try {
374392
const res = await fetch(endpoint + '/api/levelcode/v1/auth/refresh', {
375393
method: 'POST',
376394
headers: { 'content-type': 'application/json' },
377-
body: JSON.stringify({ refresh })
395+
body: JSON.stringify({ refresh: was.refresh }),
396+
signal: ac.signal
378397
});
379-
const data = await res.json().catch(() => null);
398+
data = await res.json().catch(() => null);
380399
outcome = session.classifyRefresh({ status: res.status, body: data });
381400
dbg('cloud.refresh', { outcome, status: res.status, code: data && data.error && data.error.code });
382-
if (outcome === 'ok') {
401+
} catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); }
402+
finally { clearTimeout(deadline); } // only now: the deadline covers reading the body too
403+
if (outcome === 'retry') { return false; }
404+
if (outcome === 'expired') {
405+
if (await sessionExpired(was)) { return false; }
406+
} else {
407+
const renewed = await withSessionLock(async () => {
408+
if (!await sameSession(was)) { return false; }
383409
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, data.access || data.token);
384410
if (data.refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, data.refresh); }
385411
return true;
386-
}
387-
} catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); }
388-
if (outcome === 'expired') { await sessionExpired(); }
389-
return false;
412+
});
413+
if (renewed) { return true; }
414+
}
415+
// Not applied: the stored session is no longer the one this request was about — a sign-in, a
416+
// sign-out, another refresh that finished first, or another window. What the caller needs is
417+
// whether a token is in place to retry with.
418+
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
419+
dbg('cloud.refresh', { superseded: true, outcome, token: !!token });
420+
if (!token && cloudSignedIn) {
421+
// Gone, and not by this window's hand — a sign-out or an expiry HERE clears the flag before it
422+
// deletes anything. Another window got there first, so this one catches up: the card if the
423+
// session ended, and a popover and footer that stop claiming it is live either way.
424+
cloudSignedIn = false;
425+
if (sessionExpiredPending()) { postSessionExpired(); }
426+
await postAccount(false);
427+
sendConfigToWebview();
428+
}
429+
return !!token;
430+
}
431+
432+
/**
433+
* Every change to the stored session — a sign-in, a sign-out, a refresh's new tokens, an expiry —
434+
* runs through here, one at a time.
435+
*
436+
* Each of them is several awaits long (two secrets and some globalState), and they are started by
437+
* things that do not know about each other: the auth callback, a button, a timer on window focus.
438+
* Without this, a sign-in could land between an expiry deciding "this is still the dead session" and
439+
* its deletes, and lose the pair it had just stored. Nothing slow may run inside — above all not the
440+
* network — and nothing inside may call this again.
441+
*/
442+
let sessionQueue = Promise.resolve();
443+
/** @template T @param {() => Promise<T>} fn @returns {Promise<T>} */
444+
function withSessionLock(fn) {
445+
const run = sessionQueue.then(fn);
446+
sessionQueue = run.then(() => { }, () => { }); // a failed change must not wedge the ones behind it
447+
return run;
390448
}
391449

450+
/** How many sessions this window has been through: a sign-in, a sign-out and an expiry each bump it. */
451+
let sessionGeneration = 0;
452+
392453
/**
393-
* The cloud session is over and cannot be renewed: forget the dead credentials, tell the webview,
394-
* and resync the account popover so it stops claiming the user is signed in.
454+
* Is the stored session still the one `was` describes? Call it INSIDE withSessionLock, immediately
455+
* before acting on the answer.
456+
*
457+
* Two tests, because there are two ways for it to have changed. The generation counts what THIS
458+
* window did, and under the lock it is exact. The refresh token itself catches what ANOTHER window
459+
* did, which no counter in this process can see. That half is best-effort — another process can
460+
* still write between this read and what follows — but it turns "any late answer" into
461+
* "a few milliseconds".
462+
*/
463+
async function sameSession(was) {
464+
return was.generation === sessionGeneration && await ctx.secrets.get(ACCOUNT_REFRESH_KEY) === was.refresh;
465+
}
466+
467+
/**
468+
* The cloud session `was` is over and cannot be renewed: forget its dead credentials, tell the
469+
* webview, and resync the account popover so it stops claiming the user is signed in. Returns
470+
* whether it ended anything — false when the stored session is no longer that one (see
471+
* sameSession), in which case it touches nothing.
395472
*
396473
* The cached profile is deliberately KEPT — the sign-in card can say who it is talking to, and the
397474
* next sign-in overwrites it anyway. What must go is anything the editor would otherwise keep
398475
* presenting as a live session: the tokens, and the `cloudSignedIn` flag the footer and model gate
399-
* read. Idempotent, so every path that discovers the expiry can call it without coordination.
476+
* read. A session ends once: a second caller holding the same dead session finds it already gone.
400477
*
401478
* It also leaves ACCOUNT_EXPIRED_KEY behind, because "the tokens are gone" says nothing afterwards:
402479
* that is just as true of someone who signed out, or who never signed in. The marker is what
403480
* records that a session ENDED here and the user has not answered it yet — sessionExpiredPending()
404481
* has the two things that hang off it.
405482
*/
406-
let sessionExpiredAnnounced = false;
407-
async function sessionExpired() {
408-
const hadToken = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY));
409-
cloudSignedIn = false;
410-
if (ctx) {
483+
async function sessionExpired(was) {
484+
if (!ctx) { return false; }
485+
const ended = await withSessionLock(async () => {
486+
if (!await sameSession(was)) { return false; }
487+
sessionGeneration++;
488+
cloudSignedIn = false;
411489
// Marker FIRST: if the editor dies between these writes, tokens-without-marker would read as an
412490
// ordinary sign-out and the card would be lost; marker-with-tokens just finds the expiry again.
413491
await ctx.globalState.update(ACCOUNT_EXPIRED_KEY, true);
414492
await ctx.secrets.delete(ACCOUNT_TOKEN_KEY);
415493
await ctx.secrets.delete(ACCOUNT_REFRESH_KEY);
416-
}
417-
if (!hadToken && sessionExpiredAnnounced) { return; }
418-
sessionExpiredAnnounced = true;
419-
const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {};
494+
return true;
495+
});
496+
if (!ended) { return false; }
497+
const p = ctx.globalState.get(ACCOUNT_PROFILE_KEY) || {};
420498
dbg('cloud.sessionExpired', { name: p.name || p.email || '' });
421499
postSessionExpired();
422500
await postAccount(false);
423501
sendConfigToWebview();
502+
return true;
424503
}
425504

426505
/** Show the sign-in card. The profile outlives the session so the card can say who it is talking to. */
@@ -454,11 +533,16 @@ async function clearSessionExpired() {
454533
}
455534

456535
/**
457-
* Show the card in a webview that was not there to see the session end — the chat was closed, or
458-
* this is a fresh document (a new chat, a resumed session, the chat moved to an editor tab). Called
459-
* LAST on `ready`, so the card lands under a replayed transcript instead of above it. When the
460-
* check earlier in the same `ready` is what found the expiry, this posts the card a second time;
461-
* the webview keeps one, at the bottom, which is where it belongs.
536+
* Put the card (back) in the transcript while the expiry is unanswered. Two kinds of caller:
537+
*
538+
* - `ready`: a document that was not there to see the session end — the chat was closed, or it
539+
* has just moved to an editor tab. Called LAST there, so the card lands under a replayed
540+
* transcript instead of above it. When the check earlier in the same `ready` is what found the
541+
* expiry, this posts the card a second time; the webview keeps one, at the bottom.
542+
* - anything that rewrites the transcript IN PLACE, with no new `ready` to follow: New Chat and a
543+
* resumed session (both post `reset`, which empties the log) and a checkpoint restore (which
544+
* drops every node after the restored turn). Each takes the card with it, and the next message
545+
* would be stopped by an expiry nothing on screen mentions any more.
462546
*/
463547
async function replaySessionExpired() {
464548
if (!sessionExpiredPending() || await ctx.secrets.get(ACCOUNT_TOKEN_KEY)) { return; }
@@ -1227,6 +1311,7 @@ async function resumeSession(id) {
12271311
if (lastUser) { lastAgentGoal = lastUser.text; } // keep Continue/Retry meaningful
12281312
post({ type: 'reset' });
12291313
post({ type: 'sessionResumed', id, title: (r.entry && r.entry.title) || 'Session', note: r.note || '', tier: r.plan && r.plan.tier, turns });
1314+
await replaySessionExpired(); // `reset` took the card; it goes back under the restored transcript
12301315
postContextFiles();
12311316
refreshSessions(); // the resumed session bumps to the top — keep both surfaces current
12321317
focusChatView('resumeSession');
@@ -1311,6 +1396,7 @@ function newChat() {
13111396
post({ type: 'reset' });
13121397
postContextFiles();
13131398
postMemoryDigest(); // the fresh empty state shows the welcome-back strip
1399+
replaySessionExpired().catch(() => { }); // `reset` emptied the log, and an unanswered expiry's card with it
13141400
}
13151401

13161402
/** The currently open file as a context block (capped), or null. */
@@ -1426,6 +1512,7 @@ async function restoreCheckpoint(turnId) {
14261512
checkpoints.length = idx; // drop restored + all later checkpoints (no redo)
14271513
dbg('checkpoint.restore', { turnId: turnId, files: restored, truncatedAt: cutIdx });
14281514
post({ type: 'checkpointRestored', turnId: turnId, filesRestored: restored });
1515+
await replaySessionExpired(); // the restore drops every node after this turn — the sign-in card among them
14291516
}
14301517

14311518
/** Pending in-chat approval requests, keyed by id, resolved by the webview. */
@@ -3016,13 +3103,16 @@ async function accountSignIn(provider, create) {
30163103
await vscode.env.openExternal(vscode.Uri.parse(url));
30173104
}
30183105
async function accountSignOut() {
3019-
cloudSignedIn = false;
3020-
if (ctx) {
3021-
await ctx.secrets.delete(ACCOUNT_TOKEN_KEY);
3022-
await ctx.secrets.delete(ACCOUNT_REFRESH_KEY);
3023-
await ctx.globalState.update(ACCOUNT_PROFILE_KEY, undefined);
3024-
await clearSessionExpired(); // signing out on purpose answers an expiry: no card, BYOK from here
3025-
}
3106+
await withSessionLock(async () => {
3107+
sessionGeneration++; // a refresh still out when this ran must not sign the user back in
3108+
cloudSignedIn = false;
3109+
if (ctx) {
3110+
await ctx.secrets.delete(ACCOUNT_TOKEN_KEY);
3111+
await ctx.secrets.delete(ACCOUNT_REFRESH_KEY);
3112+
await ctx.globalState.update(ACCOUNT_PROFILE_KEY, undefined);
3113+
await clearSessionExpired(); // signing out on purpose answers an expiry: no card, BYOK from here
3114+
}
3115+
});
30263116
dbg('account.signout');
30273117
await postAccount();
30283118
}
@@ -3066,13 +3156,16 @@ async function webHandoffUrl() {
30663156
/** Persist an editor session: access token (required), optional refresh token, and display profile. */
30673157
async function storeSession(access, refresh, profile) {
30683158
if (!ctx || !access) { return; }
3069-
cloudSignedIn = true;
3070-
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
3071-
if (refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, refresh); }
3072-
await ctx.globalState.update(ACCOUNT_PROFILE_KEY, {
3073-
name: (profile && profile.name) || '', email: (profile && profile.email) || '', plan: (profile && profile.plan) || ''
3159+
await withSessionLock(async () => {
3160+
sessionGeneration++; // a refresh still out for the session this replaces must not touch the new one
3161+
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
3162+
if (refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, refresh); }
3163+
cloudSignedIn = true;
3164+
await ctx.globalState.update(ACCOUNT_PROFILE_KEY, {
3165+
name: (profile && profile.name) || '', email: (profile && profile.email) || '', plan: (profile && profile.plan) || ''
3166+
});
3167+
await clearSessionExpired(); // signed in again: the expiry is answered
30743168
});
3075-
await clearSessionExpired(); // signed in again: the expiry is answered
30763169
}
30773170

30783171
// The browser redirects to levelcode://levelcode.levelcode-ai/auth/callback with either:

‎extensions/levelcode-ai/providers/session.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
/** Refresh this far ahead of expiry, so a request issued right now cannot land after the deadline. */
1515
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;
1616

17+
/**
18+
* How long the whole refresh exchange may take — connecting, the headers AND the body. The webview's
19+
* `ready` waits on a refresh before it restores the chat, so this is how long a host that accepts the
20+
* connection and then says nothing can hold that up. Running out of it is "this attempt failed",
21+
* never "the session is over".
22+
*/
23+
const REFRESH_TIMEOUT_MS = 10 * 1000;
24+
1725
/** The sentence shown when the session is gone. Mirrors the server's own wording. */
1826
const SESSION_EXPIRED_MESSAGE = 'Your LevelCode Cloud session has expired. Sign in again to continue.';
1927

@@ -79,4 +87,4 @@ function isSessionExpiredError(e) {
7987
return status === 401 || /\bAPI 401\b|token_expired|refresh_expired|signature has expired/i.test(msg);
8088
}
8189

82-
module.exports = { EXPIRY_MARGIN_MS, SESSION_EXPIRED_MESSAGE, jwtExpiresAt, accessNeedsRefresh, classifyRefresh, isSessionExpiredError };
90+
module.exports = { EXPIRY_MARGIN_MS, REFRESH_TIMEOUT_MS, SESSION_EXPIRED_MESSAGE, jwtExpiresAt, accessNeedsRefresh, classifyRefresh, isSessionExpiredError };

0 commit comments

Comments
 (0)