@@ -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 */
366379async function refreshCloudToken ( ) {
367380 if ( ! ctx ) { return false ; }
368381 const endpoint = cloudApiUrl ( ) ;
369382 if ( ! / ^ h t t p s : \/ \/ / i. test ( endpoint ) && ! / ^ h t t p : \/ \/ ( l o c a l h o s t | 1 2 7 \. 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 */
463547async 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}
30183105async 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. */
30673157async 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:
0 commit comments