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