Merge pull request #52703 from NousResearch/bb/desktop-resume-cross-wired-cache

fix(desktop): reject cross-wired runtime-id cache on session resume (salvage #50464)
This commit is contained in:
brooklyn! 2026-06-25 16:17:14 -05:00 committed by GitHub
commit 43f9d24513
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 180 additions and 5 deletions

View file

@ -4,6 +4,7 @@ import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
@ -282,3 +283,147 @@ describe('resumeSession failure recovery', () => {
expect(resumeParams).not.toHaveProperty('eager_build')
})
})
interface CacheHarnessProps {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionIdRef: MutableRefObject<string | null>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
}
// Harness that lets the test own the two cache maps so it can pre-seed a
// cross-wired runtime-id mapping and observe whether the warm fast-path trusts
// it. Mirrors the production wiring from use-session-state-cache.
function CacheHarness({
onReady,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef
}: CacheHarnessProps) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId: null,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
const clientState = (storedSessionId: string | null): ClientSessionState => createClientSessionState(storedSessionId)
describe('resumeSession warm-cache mapping integrity', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
it('rejects a cross-wired runtime mapping and falls through to a full resume', async () => {
// A recycled runtime id ('rt-recycled') is mapped to 'stored-A', but its
// cached state actually belongs to a DIFFERENT session ('stored-B') — the
// exact "open chat A, chat B loads" corruption a reaped/respawned pooled
// backend can leave behind.
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-recycled']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-recycled', clientState('stored-B')]])
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'rt-A-fresh', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<CacheHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
// The fast-path did NOT short-circuit on the cross-wired cache — the full
// resume RPC ran, for the session that was actually requested.
const resumeCalls = requestGateway.mock.calls.filter(([method]) => method === 'session.resume')
expect(resumeCalls.length).toBe(1)
expect(resumeCalls[0][1]).toMatchObject({ session_id: 'stored-A' })
// The corrupt mapping was purged so it can't mis-resolve again.
expect(runtimeIdByStoredSessionIdRef.current.has('stored-A')).toBe(false)
expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false)
})
it('honours a warm cache entry whose stored id matches (no needless refetch)', async () => {
// Correctly-wired mapping: 'rt-A' <-> 'stored-A'. The fast-path should trust
// it and never reach session.resume (only the lightweight usage probe).
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', clientState('stored-A')]])
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.usage') {
return { input: 0, output: 0, total: 0 } as never
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<CacheHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
// Fast-path served the session from cache: no full resume RPC, mapping intact.
const methods = requestGateway.mock.calls.map(([method]) => method)
expect(methods).not.toContain('session.resume')
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
})

View file

@ -591,9 +591,34 @@ export function useSessionActions({
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
// A warm cache entry is only trustworthy when it still BELONGS to the
// session being resumed. A pooled profile backend that gets idle-reaped
// and respawned (pruneSecondaryGateways) re-mints runtime ids, so a
// recycled id can resolve to a live-but-DIFFERENT session's cache entry.
// The session.usage 404 guard below only catches a fully-DEAD id — a
// recycled-live id 200s, so an unchecked hit paints the wrong transcript
// under the current route (the "open chat A, chat B loads" bug). On a
// mismatch the mapping is cross-wired: purge both sides and report a miss
// so the caller falls through to a full resume that rebinds a correct id.
const takeWarmCache = (): { runtimeId: string; state: ClientSessionState } | null => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
if (!runtimeId || !state) {
return null
}
if (state.storedSessionId !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(runtimeId)
return null
}
return { runtimeId, state }
}
if (!takeWarmCache()) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
@ -612,10 +637,14 @@ export function useSessionActions({
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
// Re-check after the profile-resolve / gateway-swap awaits above: the
// cache may have changed, and takeWarmCache re-validates belongs-to and
// purges a cross-wired mapping before we trust the fast-path.
const warmHit = takeWarmCache()
if (cachedRuntimeId && cachedState) {
if (warmHit) {
const cachedRuntimeId = warmHit.runtimeId
const cachedState = warmHit.state
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
@ -714,6 +743,7 @@ export function useSessionActions({
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
// The rejection is consumed by the `await` below; this guard only
// keeps it from surfacing as unhandled while the prefetch settles.
resumePromise.catch(() => undefined)