@@ -426,20 +426,37 @@ async function renewSession(endpoint) {
426426 // Not applied: the stored session is no longer the one this request was about — a sign-in, a
427427 // sign-out, another refresh that finished first, or another window. What the caller needs is
428428 // whether a token is in place to retry with.
429- const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY );
429+ const token = await storedToken( );
430430 dbg('cloud.refresh', { superseded: true, outcome, token: !!token });
431- if (!token && cloudSignedIn) {
432- // Gone, and not by this window's hand — a sign-out or an expiry HERE clears the flag before it
433- // deletes anything. Another window got there first, so this one catches up: the card if the
434- // session ended, and a popover and footer that stop claiming it is live either way.
435- cloudSignedIn = false;
436- if (sessionExpiredPending()) { postSessionExpired(); }
437- await postAccount(false);
438- sendConfigToWebview();
439- }
431+ if (!!token !== cloudSignedIn) { await catchUpWithStoredSession(token); }
440432 return !!token;
441433}
442434
435+ /**
436+ * The stored access token, read between session changes rather than in the middle of one. Every
437+ * change this window makes moves the token and `cloudSignedIn` together inside the lock, so a token
438+ * read this way that disagrees with the flag was changed by ANOTHER window.
439+ */
440+ function storedToken() {
441+ return withSessionLock(() => ctx.secrets.get(ACCOUNT_TOKEN_KEY));
442+ }
443+
444+ /**
445+ * The stored session belongs to every window, and a window only hears about the changes it makes
446+ * itself. When what is stored stops matching what this window is showing, another window has signed
447+ * out, signed in, or found the expiry — and a chat that is already open sends no second `ready` to
448+ * notice. Bring this window level: the flag, the popover and footer, and the card. An expiry that
449+ * is now waiting gets its card; if none is (the other window signed in), the account message takes
450+ * a card that is still up back down.
451+ * @param {string|undefined} token what SecretStorage holds now
452+ */
453+ async function catchUpWithStoredSession(token) {
454+ cloudSignedIn = !!token;
455+ if (!token && sessionExpiredPending()) { postSessionExpired(); }
456+ await postAccount(false);
457+ sendConfigToWebview();
458+ }
459+
443460/**
444461 * Every change to the stored session — a sign-in, a sign-out, a refresh's new tokens, an expiry —
445462 * runs through here, one at a time.
@@ -580,11 +597,21 @@ async function clearSessionExpired() {
580597 * resumed session (both post `reset`, which empties the log) and a checkpoint restore (which
581598 * drops every node after the restored turn). Each takes the card with it, and the next message
582599 * would be stopped by an expiry nothing on screen mentions any more.
600+ *
601+ * Never rejects. It runs at the end of things that have already done their work — a resume, a
602+ * restore — and a store that will not read is no reason to cut those short.
583603 */
584604async function replaySessionExpired() {
585- if (!sessionExpiredPending() || await ctx.secrets.get(ACCOUNT_TOKEN_KEY)) { return; }
586- dbg('cloud.sessionExpired.replay', {});
587- postSessionExpired();
605+ if (!sessionExpiredPending()) { return; }
606+ try {
607+ const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
608+ // Asked AGAIN, because the answer can change while that read is out: "Use my own key instead"
609+ // clears the marker, a Settings change takes the session out of play. Posting on the earlier
610+ // answer would put back a card the user has just dismissed.
611+ if (token || !sessionExpiredPending()) { return; }
612+ dbg('cloud.sessionExpired.replay', {});
613+ postSessionExpired();
614+ } catch (e) { dbg('cloud.sessionExpired.replay', { error: String((e && e.message) || e) }); }
588615}
589616
590617/**
@@ -593,19 +620,32 @@ async function replaySessionExpired() {
593620 *
594621 * Cheap by design: the access token's own `exp` is read locally and the network is only touched
595622 * when it is expired or about to be. A session with hours left costs nothing here. Called on the
596- * webview's `ready` and again when the window regains focus after a while away.
623+ * webview's `ready` and again every time the window regains focus.
624+ *
625+ * Two halves. Catching up with the other windows is one local read, so it happens on every call.
626+ * The half that can cost a request is rationed on focus. Never rejects: `ready` awaits this before
627+ * it restores the chat.
597628 */
629+ const SESSION_CHECK_EVERY_MS = 10 * 60 * 1000;
598630let lastSessionCheck = 0;
599631async function checkCloudSession(reason) {
600632 if (!ctx || providerMode() !== 'gateway') { return; }
601- const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
602- // No token, so no session to check. One that already ENDED — found while no chat was open to hear
603- // about it — is not announced from here: `ready` replays it last, via replaySessionExpired().
604- if (!token) { return; }
605- lastSessionCheck = Date.now();
606- if (!session.accessNeedsRefresh(token)) { return; }
607- dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) });
608- await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside
633+ try {
634+ const token = await storedToken();
635+ // First, whether this window is still showing the session that is actually stored. On `ready`
636+ // there is nothing to catch up on — the handler has just read the same token — and an expiry
637+ // found while no chat was open is replayed LAST there, by replaySessionExpired(). On focus this
638+ // is what tells an open chat that another window has ended, left or renewed the session.
639+ if (!!token !== cloudSignedIn) { await catchUpWithStoredSession(token); }
640+ if (!token) { return; }
641+ // From here on it can cost a request, so focus gets a ration: a window clicked in and out of
642+ // while offline must not retry the refresh on every click.
643+ if (reason === 'focus' && Date.now() - lastSessionCheck <= SESSION_CHECK_EVERY_MS) { return; }
644+ lastSessionCheck = Date.now();
645+ if (!session.accessNeedsRefresh(token)) { return; }
646+ dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) });
647+ await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside
648+ } catch (e) { dbg('cloud.sessionCheck', { reason, error: String((e && e.message) || e) }); }
609649}
610650
611651/** Gateway-mode token refresh (the streaming 401 retry path). Delegates to refreshCloudToken. */
@@ -1455,7 +1495,7 @@ function newChat() {
14551495 post({ type: 'reset' });
14561496 postContextFiles();
14571497 postMemoryDigest(); // the fresh empty state shows the welcome-back strip
1458- replaySessionExpired().catch(() => { }); // `reset` emptied the log, and an unanswered expiry's card with it
1498+ replaySessionExpired(); // `reset` emptied the log, and an unanswered expiry's card with it
14591499}
14601500
14611501/** The currently open file as a context block (capped), or null. */
@@ -3218,13 +3258,25 @@ async function webHandoffUrl() {
32183258 return (data && data.url) || null;
32193259 } catch (e) { dbg('account.handoff', { error: String((e && e.message) || e) }); return null; }
32203260}
3221- /** Persist an editor session: access token (required), optional refresh token, and display profile. */
3261+ /**
3262+ * Persist an editor session: access token (required), the refresh token if the sign-in brought one,
3263+ * and the display profile.
3264+ *
3265+ * A sign-in REPLACES the session; it does not top one up. A refresh token left over from whatever
3266+ * was here before is what the new session's first renewal would be made with: refused, it ends the
3267+ * session that replaced it; still good, it hands this editor the previous account's access token
3268+ * under the new account's name. So the refresh token is settled first — stored, or forgotten when
3269+ * there is none — and only then the access token. A sign-in cut short between the two (the editor
3270+ * closing, a keychain that will not write) must not leave the new access token over the old
3271+ * refresh token either.
3272+ */
32223273async function storeSession(access, refresh, profile) {
32233274 if (!ctx || !access) { return; }
32243275 await withSessionLock(async () => {
32253276 sessionGeneration++; // a refresh still out for the session this replaces must not touch the new one
3226- await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
32273277 if (refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, refresh); }
3278+ else { await ctx.secrets.delete(ACCOUNT_REFRESH_KEY); }
3279+ await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
32283280 cloudSignedIn = true;
32293281 await ctx.globalState.update(ACCOUNT_PROFILE_KEY, {
32303282 name: (profile && profile.name) || '', email: (profile && profile.email) || '', plan: (profile && profile.plan) || ''
@@ -3318,15 +3370,17 @@ function onConfigChanged(e) {
33183370 if (e.affectsConfiguration('levelcode.ai')) { sendConfigToWebview(); }
33193371 if (e.affectsConfiguration('levelcode.ai.providerMode') || e.affectsConfiguration('levelcode.cloud')) {
33203372 postAccount();
3321- replaySessionExpired().catch(() => { }) ;
3373+ replaySessionExpired();
33223374 }
33233375}
33243376
33253377function activate(context) {
3326- // A window that comes back after a while away may have outlived its access token (8 h). Re-check
3327- // on focus, throttled, so the expiry is found before the next message rather than by it.
3378+ // A window that comes back after a while away may have outlived its access token (8 h) — or
3379+ // another window may have ended, left or renewed the session they share. Re-check on every focus,
3380+ // so either is found before the next message rather than by it. The look is one local read;
3381+ // checkCloudSession rations the part that can cost a request.
33283382 context.subscriptions.push(vscode.window.onDidChangeWindowState((st) => {
3329- if (st.focused && Date.now() - lastSessionCheck > 10 * 60 * 1000) { checkCloudSession('focus').catch(() => {} ); }
3383+ if (st.focused) { checkCloudSession('focus'); }
33303384 }));
33313385 ctx = context;
33323386 // Constructed directly rather than by registerWebviewViewProvider: the chat is no longer a
0 commit comments