From c59e234dbb74c65b92285caba50fba76f61d39ae Mon Sep 17 00:00:00 2001 From: Bruno Bza Date: Wed, 26 Aug 2026 08:58:12 +0200 Subject: [PATCH] fix(desktop): release reconnect-orphaned warm transcripts once their authoritative state settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gateway connection that dies mid-turn leaves cached session snapshots whose busy/awaitingResponse flags can never settle: the respawned backend re-mints runtime ids, so no terminal publish ever reaches the orphaned snapshot again. #isWarmSettled treated those frozen flags as live work, so every orphan pinned its full warm transcript until app restart — roughly 5MB per reconnect cycle, which turned the restart loop in #95189 into renderer OOM. SessionStateCache now accepts an optional isAuthoritativelyActive probe. When wired, in-flight flags only block eviction while the authoritative $sessionStates record still claims work for the same runtime id; without the probe the legacy always-block behavior is preserved byte-for-byte. Eviction remains gated on needsInput, pending drafts, and active references, so a genuinely running turn (which re-asserts busy on every publish) is never a casualty. The useSessionStateCache hook wires the probe to the store it already imports. Reconnect reconciliation (reconcileBusyStatesOnReconnect) settles the authoritative record, and the next prune drains the orphaned cache entry through the normal LRU path, ownership included. --- .../hooks/use-session-state-cache.test.tsx | 114 ++++++++++++++++++ .../session/hooks/use-session-state-cache.ts | 12 +- .../app/session/session-state-cache.test.ts | 95 +++++++++++++++ .../src/app/session/session-state-cache.ts | 21 +++- 4 files changed, 239 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx index bb6f4e74a81e2..5640e70bb9b18 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx @@ -519,4 +519,118 @@ describe('useSessionStateCache — cross-thread error isolation', () => { cache.runtimeIdByStoredSessionIdRef.current.set('stored-A', 'runtime-B') expect(cache.getRuntimeIdForStoredSession('stored-A')).toBeNull() }) + + describe('reconnect-orphaned transcripts (#95189)', () => { + // Unique per-test runtime/stored ids: earlier describes in this file leave + // states in $sessionStates, and a recycled id would let this test's + // authority probe read THEIR stale flags instead of its own. + const bg = 'orphan-bg-runtime' + const fg = 'orphan-fg-runtime' + const bgStored = 'orphan-bg-stored' + const fgStored = 'orphan-fg-stored' + + beforeEach(() => { + $sessionStates.set({}) + setActiveSessionId(null) + }) + + afterEach(() => { + $sessionStates.set({}) + setActiveSessionId(null) + }) + + it('releases a busy transcript once reconciliation settles the authoritative record', () => { + let cache!: Cache + + render( (cache = value)} selectedStoredSessionId={fgStored} />) + + act(() => { + // A mid-turn session carries a growing transcript: without messages + // there would be nothing warm to release. + cache.updateSessionState( + bg, + state => ({ + ...state, + busy: true, + messages: [ + { id: `${bg}-user`, role: 'user', parts: [{ type: 'text', text: 'hello' }] }, + { id: `${bg}-assistant`, role: 'assistant', parts: [{ type: 'text', text: 'partial reply' }] } + ] + }), + bgStored + ) + }) + + expect($sessionStates.get()[bg]?.busy).toBe(true) + expect(cache.sessionStateByRuntimeIdRef.current.has(bg)).toBe(true) + + act(() => { + // The minting socket died mid-turn; reconnect reconciliation + // (reconcileBusyStatesOnReconnect) downgrades the authoritative + // record, and the respawned backend re-mints runtime ids so no event + // will ever settle this snapshot's own busy flag again. + const states = $sessionStates.get() + + $sessionStates.set({ + ...states, + [bg]: { ...states[bg]!, busy: false, awaitingResponse: false } + }) + }) + + // Production caches are bounded by the class defaults (24 sessions / + // 32MB), and prune only drains once that budget is exceeded. Simulate + // the reconnect churn of #95189: a stream of settled sessions pushes + // the cache past its cap, and the orphaned entry — oldest touched, + // finally warm-eligible now that the authoritative record settled — + // must be the first thing drained, ownership included. + const liveBusy = `${bg}-still-working` + + act(() => { + cache.updateSessionState( + liveBusy, + state => ({ + ...state, + busy: true, + messages: [{ id: `${liveBusy}-u`, role: 'user', parts: [{ type: 'text', text: 'long turn' }] }] + }), + `${liveBusy}-stored` + ) + }) + + for (let i = 0; i < 24; i += 1) { + act(() => { + cache.updateSessionState( + `${bg}-churn-${i}`, + state => ({ + ...state, + messages: [{ id: `churn-${i}`, role: 'user', parts: [{ type: 'text', text: `done ${i}` }] }] + }), + `${bg}-churn-${i}-stored` + ) + }) + } + + expect(cache.sessionStateByRuntimeIdRef.current.has(bg)).toBe(false) + expect(cache.runtimeIdByStoredSessionIdRef.current.has(bgStored)).toBe(false) + // A genuinely running turn is never a casualty of the drain. + expect(cache.sessionStateByRuntimeIdRef.current.has(liveBusy)).toBe(true) + }) + + it('keeps a live background turn cached while its authoritative record is busy', () => { + let cache!: Cache + + render( (cache = value)} selectedStoredSessionId={fgStored} />) + + act(() => { + cache.updateSessionState(bg, state => ({ ...state, busy: true }), bgStored) + }) + + act(() => { + cache.updateSessionState(fg, state => ({ ...state, model: 'test/model' })) + }) + + expect(cache.sessionStateByRuntimeIdRef.current.has(bg)).toBe(true) + expect(cache.runtimeIdByStoredSessionIdRef.current.get(bgStored)).toBe(bg) + }) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 04c738cc4e21b..89550e3985e0e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -20,7 +20,7 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' -import { $sessionTiles, publishSessionState, releaseSessionTranscript } from '@/store/session-states' +import { $sessionStates, $sessionTiles, publishSessionState, releaseSessionTranscript } from '@/store/session-states' import type { ClientSessionState } from '../../types' import { SessionStateCache } from '../session-state-cache' @@ -98,6 +98,16 @@ export function useSessionStateCache({ tile.runtimeId === runtimeId || (state.storedSessionId !== null && tile.storedSessionId === state.storedSessionId) ), + // A connection death mid-turn leaves snapshots whose frozen busy flags + // will never settle (the respawned backend re-mints runtime ids), which + // pinned megabytes of warm transcript per reconnect cycle behind + // #isWarmSettled (#95189). Trust the cached in-flight flags only while + // the authoritative store still claims work for the same runtime id. + isAuthoritativelyActive: runtimeId => { + const live = $sessionStates.get()[runtimeId] + + return Boolean(live && (live.busy || live.awaitingResponse)) + }, onEvict: (runtimeId, state) => { // Ownership is removed with the transcript, but only if both sides still // describe this exact binding. A recycled runtime must not erase its diff --git a/apps/desktop/src/app/session/session-state-cache.test.ts b/apps/desktop/src/app/session/session-state-cache.test.ts index a52ce3238eff4..ecdeb7f22edcf 100644 --- a/apps/desktop/src/app/session/session-state-cache.test.ts +++ b/apps/desktop/src/app/session/session-state-cache.test.ts @@ -130,4 +130,99 @@ describe('SessionStateCache', () => { expect($sessionStates.get().runtime).toMatchObject({ storedSessionId: 'stored', busy: false, needsInput: false }) expect($sessionStates.get().runtime.messages).toEqual([]) }) + + describe('authoritative liveness probe (#95189)', () => { + function cacheWithAuthority(evicted: string[]): SessionStateCache { + return new SessionStateCache( + { + isReferenced: () => false, + onEvict: runtimeId => evicted.push(runtimeId), + isAuthoritativelyActive: runtimeId => { + const live = $sessionStates.get()[runtimeId] + + return Boolean(live && (live.busy || live.awaitingResponse)) + } + }, + { maxBytes: 0, maxCount: 0 } + ) + } + + it.each([ + ['busy', (state: ClientSessionState) => ({ ...state, busy: true })], + ['awaiting', (state: ClientSessionState) => ({ ...state, awaitingResponse: true })] + ])('evicts an orphaned %s transcript once the authoritative record settles', (_label, decorate) => { + const evicted: string[] = [] + const cache = cacheWithAuthority(evicted) + const orphaned = decorate(settled('orphaned')) + + // Mid-turn the snapshot and the authoritative record agree: protection + // must hold exactly as it does without the probe. + $sessionStates.set({ orphaned }) + cache.set('orphaned', orphaned) + cache.prune() + expect(cache.get('orphaned')).toBe(orphaned) + + // The minting connection dies mid-turn. Reconnect reconciliation + // settles the authoritative record, but the respawned backend re-mints + // runtime ids — no event will ever reach this snapshot again, so its + // frozen in-flight flags must stop pinning the transcript. + $sessionStates.set({ orphaned: { ...orphaned, busy: false, awaitingResponse: false } }) + cache.prune() + + expect(cache.has('orphaned')).toBe(false) + expect(evicted).toEqual(['orphaned']) + }) + + it('evicts an in-flight transcript whose authoritative record was dropped entirely', () => { + const evicted: string[] = [] + const cache = cacheWithAuthority(evicted) + const working = { ...settled('working'), busy: true } + + $sessionStates.set({ working }) + cache.set('working', working) + cache.prune() + expect(cache.has('working')).toBe(true) + + // A soft gateway-mode apply wipes every authoritative state; surviving + // snapshots describe dead runtimes (#95189 reconnect churn). + $sessionStates.set({}) + cache.prune() + + expect(cache.has('working')).toBe(false) + expect(evicted).toEqual(['working']) + }) + + it('keeps an in-flight transcript pinned while the authoritative store still claims work', () => { + const evicted: string[] = [] + const cache = cacheWithAuthority(evicted) + const working = { ...settled('working'), busy: true } + + $sessionStates.set({ working }) + cache.set('working', working) + cache.prune() + + expect(cache.get('working')).toBe(working) + expect(evicted).toEqual([]) + }) + + it.each([ + ['busy', (state: ClientSessionState) => ({ ...state, busy: true })], + ['awaiting', (state: ClientSessionState) => ({ ...state, awaitingResponse: true })] + ])('still never evicts %s transcripts when no authority probe is wired', (_label, decorate) => { + // Legacy construction: without the probe there is no way to tell a live + // turn from an orphaned snapshot, so the flags keep blocking eviction. + const working = decorate(settled('working')) + $sessionStates.set({ working: { ...working, busy: false, awaitingResponse: false } }) + + const cache = new SessionStateCache( + { isReferenced: () => false, onEvict: () => undefined }, + { maxBytes: 0, maxCount: 0 } + ) + + cache.set('working', working) + cache.prune() + + expect(cache.get('working')).toBe(working) + }) + }) }) diff --git a/apps/desktop/src/app/session/session-state-cache.ts b/apps/desktop/src/app/session/session-state-cache.ts index 9c4197e6d28c9..04208fed72548 100644 --- a/apps/desktop/src/app/session/session-state-cache.ts +++ b/apps/desktop/src/app/session/session-state-cache.ts @@ -11,6 +11,14 @@ interface SessionStateCacheLimits { interface SessionStateCacheCallbacks { isReferenced: (runtimeId: string, state: ClientSessionState) => boolean onEvict: (runtimeId: string, state: ClientSessionState) => void + /** Optional liveness check for a cached snapshot's in-flight claims. A + * connection death mid-turn orphans snapshots: the respawned backend + * re-mints runtime ids, so their frozen busy/awaitingResponse flags never + * receive a settling publish (#95189) and would pin megabytes of warm + * transcript per reconnect cycle until restart. When wired, those flags + * only block eviction while the authoritative store still claims work for + * the same runtime id; without the probe they always block. */ + isAuthoritativelyActive?: (runtimeId: string, state: ClientSessionState) => boolean } function transcriptBytes(state: ClientSessionState): number { @@ -117,11 +125,20 @@ export class SessionStateCache extends Map { } #isWarmSettled(runtimeId: string, state: ClientSessionState): boolean { + // In-flight claims pin a transcript only while they are trustworthy: with + // an authority probe wired, a frozen busy/awaitingResponse on an orphaned + // snapshot stops blocking eviction (see callback docs). Without one, the + // legacy behavior holds and the flags always block. + if ( + (state.busy || state.awaitingResponse) && + this.#callbacks.isAuthoritativelyActive?.(runtimeId, state) !== false + ) { + return false + } + return ( Boolean(state.storedSessionId) && state.messages.length > 0 && - !state.busy && - !state.awaitingResponse && !state.needsInput && !hasDraftOrInFlightMessage(state) && !this.#callbacks.isReferenced(runtimeId, state)