perf(desktop): multi-tile grids stop lagging — evict leaked session states, index lineage aliases, split the turn journal (#83133)
* fix(desktop): evict settled session states nothing on screen references Closing a tile never removed its runtime's entry from $sessionStates, so every tile ever closed parked its full transcript in the map for the life of the process. Each leftover entry taxes every subsequent stream flush — the map is spread-copied per delta and the busy/attention/draft projections walk every entry per publish — so the app got slower the longer it ran, which users read as "I need to clean my sessions/dbs". Publish now evicts a settling state when no tile and not the primary view holds its runtime (transition side effects still fire, so the settle keeps its unread dot), and closing a tile drops an already-settled state on the spot. Busy and needs-input states stay: background turns feed the sidebar dots, and a first publish always lands because a resume can publish a beat before the surface binds the runtime. 16 tiles streaming in a 2x2 grid with a day's worth of closed-tile residue: worst-second 34 -> 58 fps, p99 frame 90 -> 28 ms, longtasks 37 -> 0. * perf(desktop): index lineage aliases per sessions-list reference lineageAliases scanned the whole recents list per call, and it is called per cached session state per status projection per message delta — with a populated sessions DB and a few busy sessions that multiplied out to millions of row checks a second during streaming. Build the alias index once per list reference (the list is replaced wholesale, never mutated) and look aliases up in O(1). * perf(desktop): journal each in-flight turn under its own storage key The v1 journal kept every session's tail in one localStorage key, so each throttled write re-parsed and re-stringified EVERY busy session's snapshot — a grid of concurrent streams turned that into a whole-store JSON round trip dozens of times a second, all on the main thread. Per-session keys make a write O(own tail) no matter how many other sessions are streaming. A v1 store migrates on first touch; expired/overflow crash residue is pruned once per renderer. * perf(desktop): stress the multitab scenario across grid/streaming/DB axes The one-stack multitab run hid every cost this round of fixes removed: it drove hook.publish (store only — no journal, no wiring cache), with an empty recents list and no closed-tile residue. Streaming now routes through hook.update (the real gateway write path), and the scenario grows axes for the workloads users actually hit: --zones splits tiles across visible grid zones, --streaming caps how many sessions are mid-turn (zone leaders first), --sessions seeds a lived-in recents list, --dead models settled sessions no surface references. launch.mjs pins HERMES_DESKTOP_CDP_PORT so a non-default --port survives the app's own dev-CDP flag.
This commit is contained in:
parent
c08b086013
commit
3139a30e52
|
|
@ -238,6 +238,11 @@ export async function startIsolatedInstance({
|
|||
const env = {
|
||||
...process.env,
|
||||
HERMES_HOME: home,
|
||||
// The app's dev-CDP resolver (electron/dev-cdp.ts) appends its own
|
||||
// remote-debugging-port switch AFTER argv, so on a non-default --port the
|
||||
// Chromium flag below loses and the instance binds 9222 anyway. The env
|
||||
// override is the supported knob — set it so --port actually wins.
|
||||
HERMES_DESKTOP_CDP_PORT: String(port),
|
||||
XCURSOR_SIZE: '24'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,21 @@
|
|||
// "5 tabs doing PR review" workload. Measures frame pacing + longtasks while
|
||||
// the whole stack streams, which is where multitab renderers crawl.
|
||||
//
|
||||
// Drives the real pipeline synthetically (no backend, no credits):
|
||||
// publishSessionState per session per flush — exactly what the gateway's
|
||||
// delta flush does — via the __HERMES_SESSION_TILES__ hook.
|
||||
// --zones M splits the tiles across M VISIBLE split zones (a 2×2 grid for 4)
|
||||
// instead of one tab stack — the "4 tiles with 4 sessions each" workload,
|
||||
// where M transcripts stream on screen at once and the rest are mounted
|
||||
// keep-alive tabs behind them. --streaming S caps how many sessions are
|
||||
// actually mid-turn (zone leaders first, so S=zones means "every visible
|
||||
// transcript streams, every hidden tab idles"); the rest sit settled.
|
||||
// --sessions N seeds a populated recents list (a lived-in sessions DB).
|
||||
//
|
||||
// Drives the real pipeline synthetically (no backend, no credits): each tick
|
||||
// routes one delta per streaming session through `hook.update` — the same
|
||||
// wiring-cache write (journal + publish + view sync) the gateway's delta
|
||||
// flush performs — via the __HERMES_SESSION_TILES__ hook.
|
||||
//
|
||||
// node scripts/perf/run.mjs multitab --spawn [--tiles 5] [--tokens 240]
|
||||
// node scripts/perf/run.mjs multitab --spawn --tiles 16 --zones 4 --sessions 300
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
import { frameHistogram, percentile } from '../lib/stats.mjs'
|
||||
|
|
@ -50,12 +60,19 @@ const COLLECT = `
|
|||
})()
|
||||
`
|
||||
|
||||
/** Page-side setup: open `tiles` session tiles stacked into the main zone,
|
||||
* bind fake runtime ids, and seed each with a realistic transcript. */
|
||||
const setup = (tiles, seedTurns, streamSeed) => `
|
||||
/** Page-side setup: open `tiles` session tiles — one tab stack in the main
|
||||
* zone (zones=1), or spread across `zones` visible splits (a 2×2 grid for 4)
|
||||
* — bind fake runtime ids, and seed each with a realistic transcript.
|
||||
*
|
||||
* States are written through `hook.update` — the REAL gateway write path
|
||||
* (wiring cache + in-flight journal + publish + view sync). Driving
|
||||
* `hook.publish` alone under-models a stream: it skips the journal and the
|
||||
* cache, which is exactly where multi-session cost used to hide. */
|
||||
const setup = (tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!hook.update) return 'no-update-hook'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
|
|
@ -75,29 +92,89 @@ const setup = (tiles, seedTurns, streamSeed) => `
|
|||
].join('\\n') }] }
|
||||
])
|
||||
|
||||
const state = (sid, rid) => {
|
||||
const state = (sid, rid, isStreaming) => {
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
// Streaming tail the driver grows (--code seeds an open fence).
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] })
|
||||
// Streaming tail the driver grows (--code seeds an open fence); a
|
||||
// non-streaming session sits settled — open, mounted, mid-nothing.
|
||||
if (isStreaming) {
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] })
|
||||
}
|
||||
return {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
busy: isStreaming, awaitingResponse: false,
|
||||
streamId: isStreaming ? sid + '-stream' : null, sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
needsInput: false, turnStartedAt: isStreaming ? Date.now() : null, usage: null
|
||||
}
|
||||
}
|
||||
|
||||
window.__MT__ = { ids: [], timer: null }
|
||||
// A populated recents list (--sessions): every store publish re-runs the
|
||||
// busy/attention/draft projections against it, so an empty list hides
|
||||
// that scaling. Restored by CLEANUP.
|
||||
if (${seedSessions} > 0) {
|
||||
window.__MT_SAVED_SESSIONS__ = hook.sessions()
|
||||
const rows = []
|
||||
for (let i = 0; i < ${seedSessions}; i++) {
|
||||
rows.push({
|
||||
id: 'perf-row-' + i, title: 'Seeded session ' + i, ended_at: null,
|
||||
input_tokens: 1200, output_tokens: 800, is_active: false,
|
||||
last_active: Date.now() - i * 60000, message_count: 12,
|
||||
model: 'hermes-4', preview: 'seeded row', cwd: '/tmp/proj-' + (i % 7)
|
||||
})
|
||||
}
|
||||
hook.seedSessions(rows)
|
||||
}
|
||||
|
||||
// Leaked residue (--dead): sessions that ran with no surface referencing
|
||||
// them and then settled — what a day of opening and closing tiles
|
||||
// accumulates. Modeled on the real path (insert while busy, then the
|
||||
// settle publish) so publish-time eviction, where present, engages.
|
||||
// CLEANUP drops whatever survives, for builds without eviction.
|
||||
window.__MT_DEAD__ = []
|
||||
for (let d = 0; d < ${dead}; d++) {
|
||||
const sid = 'perf-dead-' + d
|
||||
const rid = 'perf-dead-rt-' + d
|
||||
window.__MT_DEAD__.push(rid)
|
||||
const settled = state(sid, rid, false)
|
||||
hook.publish(rid, { ...settled, busy: true })
|
||||
hook.publish(rid, settled)
|
||||
}
|
||||
|
||||
// Zone leaders open as visible splits (right of the workspace, then
|
||||
// subdividing that column into a grid); followers stack as tabs into
|
||||
// their zone. zones=1 keeps the classic one-stack workload.
|
||||
const perZone = Math.ceil(${tiles} / ${zones})
|
||||
const leaders = []
|
||||
|
||||
// Streaming slots go to zone LEADERS first (rank orders round-robin across
|
||||
// zones), so --streaming ${'$'}{zones} means "every VISIBLE transcript streams,
|
||||
// every hidden tab idles" — the split the all-vs-visible snapshots diff.
|
||||
window.__MT__ = { ids: [], leaders, streaming: [], timer: null }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'perf-tile-' + n
|
||||
const rid = 'perf-rt-' + n
|
||||
window.__MT__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
const zone = ${zones} > 1 ? Math.floor((n - 1) / perZone) : 0
|
||||
const posInZone = ${zones} > 1 ? (n - 1) % perZone : n - 1
|
||||
const rank = posInZone * ${zones} + zone
|
||||
const isStreaming = rank < ${streaming}
|
||||
if (isStreaming) window.__MT__.streaming.push(rid)
|
||||
const leader = leaders[zone]
|
||||
if (leader) {
|
||||
hook.open(sid, 'center', 'session-tile:' + leader)
|
||||
} else if (${zones} === 1) {
|
||||
hook.open(sid, 'center')
|
||||
} else {
|
||||
leaders[zone] = sid
|
||||
if (zone === 0) hook.open(sid, 'right')
|
||||
else if (zone === 1) hook.open(sid, 'bottom', 'session-tile:' + leaders[0])
|
||||
else hook.open(sid, 'right', 'session-tile:' + leaders[zone - 2])
|
||||
}
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, state(sid, rid))
|
||||
hook.update(rid, () => state(sid, rid, isStreaming))
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
|
|
@ -108,23 +185,23 @@ const setup = (tiles, seedTurns, streamSeed) => `
|
|||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Page-side driver: grow every tile's streaming tail by `chunk` each
|
||||
* `intervalMs`, through the same publish path the gateway flush uses. */
|
||||
* `intervalMs`, through the same write path the gateway flush uses. */
|
||||
const drive = (chunk, intervalMs, totalTokens) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
let pushed = 0
|
||||
const tick = () => {
|
||||
const states = hook.states()
|
||||
for (const { rid } of window.__MT__.ids) {
|
||||
const prev = states[rid]
|
||||
if (!prev) continue
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts[m.parts.length - 1]
|
||||
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
||||
for (const rid of window.__MT__.streaming) {
|
||||
hook.update(rid, prev => {
|
||||
if (!prev.streamId) return prev
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts[m.parts.length - 1]
|
||||
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
||||
})
|
||||
return { ...prev, messages }
|
||||
})
|
||||
hook.publish(rid, { ...prev, messages })
|
||||
}
|
||||
pushed += 1
|
||||
if (pushed < ${totalTokens}) window.__MT__.timer = setTimeout(tick, ${intervalMs})
|
||||
|
|
@ -137,16 +214,24 @@ const drive = (chunk, intervalMs, totalTokens) => `
|
|||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (window.__MT_DEAD__) {
|
||||
for (const rid of window.__MT_DEAD__) hook.drop?.(rid)
|
||||
window.__MT_DEAD__ = null
|
||||
}
|
||||
if (window.__MT__) {
|
||||
clearTimeout(window.__MT__.timer)
|
||||
for (const { sid, rid } of window.__MT__.ids) {
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, {
|
||||
...window.__HERMES_SESSION_TILES__.states()[rid], busy: false, streamId: null
|
||||
})
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
// Settle through the real path so the in-flight journal entry clears.
|
||||
hook.update(rid, prev => ({ ...prev, busy: false, streamId: null }))
|
||||
hook.close(sid)
|
||||
}
|
||||
window.__MT__ = null
|
||||
}
|
||||
if (window.__MT_SAVED_SESSIONS__) {
|
||||
hook.seedSessions(window.__MT_SAVED_SESSIONS__)
|
||||
window.__MT_SAVED_SESSIONS__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
|
@ -157,7 +242,11 @@ export default {
|
|||
description: 'N mounted session-tile tabs all streaming: frame pacing + longtasks.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const zones = Number(opts.zones ?? 1)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const seedSessions = Number(opts.sessions ?? 0)
|
||||
const streaming = Math.min(Number(opts.streaming ?? tiles), tiles)
|
||||
const dead = Number(opts.dead ?? 0)
|
||||
const tokens = Number(opts.tokens ?? 240)
|
||||
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
||||
const intervalMs = Number(opts.intervalMs ?? 33)
|
||||
|
|
@ -172,7 +261,7 @@ export default {
|
|||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed))
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`multitab setup failed (${ok}) — dev hooks missing? (needs a dev/probe renderer)`)
|
||||
|
|
@ -184,6 +273,17 @@ export default {
|
|||
await sleep(350)
|
||||
}
|
||||
|
||||
// Front each zone's leader so the visible set is one transcript per zone
|
||||
// (the reveal loop above leaves each zone on its LAST tab).
|
||||
if (zones > 1) {
|
||||
const leaders = JSON.parse(await cdp.eval('JSON.stringify(window.__MT__.leaders)'))
|
||||
|
||||
for (const sid of leaders) {
|
||||
await cdp.eval(reveal(sid))
|
||||
await sleep(150)
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(1000)
|
||||
await cdp.eval(RECORDERS)
|
||||
await cdp.eval(drive(chunk, intervalMs, tokens))
|
||||
|
|
@ -235,6 +335,10 @@ export default {
|
|||
},
|
||||
detail: {
|
||||
tiles,
|
||||
zones,
|
||||
streaming,
|
||||
dead,
|
||||
sessions: seedSessions,
|
||||
code: Boolean(opts.code),
|
||||
windowS: Math.round(windowS * 10) / 10,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
recoverInFlightTurnJournal
|
||||
} from '@/lib/inflight-turn-journal'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
|
||||
const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
|
||||
const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
|
||||
|
||||
function user(id: string, text: string): ChatMessage {
|
||||
return { id, role: 'user', parts: [{ type: 'text', text }] }
|
||||
|
|
@ -112,12 +113,53 @@ describe('persistInFlightTurnState', () => {
|
|||
persistInFlightTurnState(journalState())
|
||||
vi.advanceTimersByTime(400)
|
||||
|
||||
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!)
|
||||
raw.entries['stored-1'].updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(raw))
|
||||
const raw = JSON.parse(window.localStorage.getItem(`${STORAGE_PREFIX}stored-1`)!)
|
||||
raw.updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000
|
||||
window.localStorage.setItem(`${STORAGE_PREFIX}stored-1`, JSON.stringify(raw))
|
||||
|
||||
expect(readInFlightTurnJournal('stored-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('writes each session under its own key, untouched by other sessions settling', () => {
|
||||
persistInFlightTurnState(journalState())
|
||||
persistInFlightTurnState(journalState({ storedSessionId: 'stored-2' }))
|
||||
vi.advanceTimersByTime(400)
|
||||
|
||||
expect(window.localStorage.getItem(`${STORAGE_PREFIX}stored-1`)).not.toBeNull()
|
||||
expect(window.localStorage.getItem(`${STORAGE_PREFIX}stored-2`)).not.toBeNull()
|
||||
|
||||
clearInFlightTurnJournal('stored-2')
|
||||
|
||||
expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
|
||||
expect(readInFlightTurnJournal('stored-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('recovers entries journaled by the v1 single-key store', () => {
|
||||
// A pre-upgrade crash leaves a v1 store behind; the first journal touch
|
||||
// after the upgrade must still recover its turns.
|
||||
window.localStorage.setItem(
|
||||
LEGACY_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
entries: {
|
||||
'stored-legacy': {
|
||||
messages: [user('u1', 'legacy prompt'), assistant('a1', 'legacy partial', { pending: true })],
|
||||
streamId: 'a1',
|
||||
turnStartedAt: 500,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
},
|
||||
version: 1
|
||||
})
|
||||
)
|
||||
|
||||
const entry = readInFlightTurnJournal('stored-legacy')
|
||||
|
||||
expect(entry?.streamId).toBe('a1')
|
||||
expect(entry?.messages).toHaveLength(2)
|
||||
expect(window.localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
|
||||
|
||||
clearInFlightTurnJournal('stored-legacy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoverInFlightTurnJournal', () => {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,13 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/c
|
|||
* Best-effort by design: storage failures must never break chat streaming.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
|
||||
const STORE_VERSION = 1
|
||||
/** One localStorage key PER SESSION. The v1 single-key store meant every
|
||||
* throttled write re-parsed and re-stringified EVERY busy session's tail —
|
||||
* with a grid of concurrent streams that was a whole-store JSON round-trip
|
||||
* dozens of times a second, all on the main thread. Per-session keys make a
|
||||
* write O(own tail) regardless of how many other sessions are streaming. */
|
||||
const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
|
||||
const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
|
||||
const MAX_ENTRIES = 24
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
/** Streaming repaints arrive every ~33ms; localStorage writes are synchronous.
|
||||
|
|
@ -41,11 +46,6 @@ export interface JournalableSessionState {
|
|||
turnStartedAt: null | number
|
||||
}
|
||||
|
||||
interface JournalStore {
|
||||
entries: Record<string, InFlightTurnSnapshot>
|
||||
version: typeof STORE_VERSION
|
||||
}
|
||||
|
||||
export interface InFlightRecoveryResult {
|
||||
applied: boolean
|
||||
/** The base transcript already contains the journaled turn's completed
|
||||
|
|
@ -64,73 +64,131 @@ function storage(): Storage | null {
|
|||
}
|
||||
}
|
||||
|
||||
function emptyStore(): JournalStore {
|
||||
return { entries: {}, version: STORE_VERSION }
|
||||
const entryKey = (storedSessionId: string) => `${STORAGE_PREFIX}${storedSessionId}`
|
||||
|
||||
function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean {
|
||||
return now - entry.updatedAt > MAX_AGE_MS
|
||||
}
|
||||
|
||||
function loadStore(): JournalStore {
|
||||
function loadEntry(storedSessionId: string): InFlightTurnSnapshot | null {
|
||||
const store = storage()
|
||||
|
||||
if (!store) {
|
||||
return emptyStore()
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = store.getItem(STORAGE_KEY)
|
||||
const raw = store.getItem(entryKey(storedSessionId))
|
||||
const parsed = raw ? (JSON.parse(raw) as InFlightTurnSnapshot) : null
|
||||
|
||||
if (!raw) {
|
||||
return emptyStore()
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.version !== STORE_VERSION ||
|
||||
typeof parsed.entries !== 'object' ||
|
||||
Array.isArray(parsed.entries)
|
||||
) {
|
||||
return emptyStore()
|
||||
}
|
||||
|
||||
return {
|
||||
entries: parsed.entries as Record<string, InFlightTurnSnapshot>,
|
||||
version: STORE_VERSION
|
||||
}
|
||||
return parsed && typeof parsed.updatedAt === 'number' && Array.isArray(parsed.messages) ? parsed : null
|
||||
} catch {
|
||||
return emptyStore()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveStore(journal: JournalStore): void {
|
||||
function saveEntry(storedSessionId: string, entry: InFlightTurnSnapshot): void {
|
||||
try {
|
||||
storage()?.setItem(entryKey(storedSessionId), JSON.stringify(entry))
|
||||
} catch {
|
||||
// Quota/private-mode failures: the journal is a recovery aid, not truth.
|
||||
}
|
||||
}
|
||||
|
||||
function removeEntry(storedSessionId: string): void {
|
||||
try {
|
||||
storage()?.removeItem(entryKey(storedSessionId))
|
||||
} catch {
|
||||
// Same best-effort stance as saveEntry.
|
||||
}
|
||||
}
|
||||
|
||||
// Split a v1 single-key store into per-session entries. Checked on every
|
||||
// journal touch (a null getItem is free); a populated v1 store exists at most
|
||||
// once, right after the upgrade.
|
||||
function migrateLegacyStore(store: Storage): void {
|
||||
try {
|
||||
const legacy = store.getItem(LEGACY_STORAGE_KEY)
|
||||
|
||||
if (!legacy) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(legacy)
|
||||
|
||||
if (parsed && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {
|
||||
for (const [id, entry] of Object.entries(parsed.entries as Record<string, InFlightTurnSnapshot>)) {
|
||||
saveEntry(id, entry)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A corrupt v1 store has nothing worth carrying over.
|
||||
}
|
||||
|
||||
try {
|
||||
store.removeItem(LEGACY_STORAGE_KEY)
|
||||
} catch {
|
||||
// Best-effort, like every other journal write.
|
||||
}
|
||||
}
|
||||
|
||||
// One-time prune per renderer: drop expired/overflow entries. Startup-only on
|
||||
// purpose — entries clear on settle, so anything left over is crash residue,
|
||||
// and enumerating localStorage on the write path would defeat the point.
|
||||
let housekeepingDone = false
|
||||
|
||||
function ensureHousekeeping(): void {
|
||||
const store = storage()
|
||||
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
|
||||
migrateLegacyStore(store)
|
||||
|
||||
if (housekeepingDone) {
|
||||
return
|
||||
}
|
||||
|
||||
housekeepingDone = true
|
||||
|
||||
try {
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(journal.entries)
|
||||
.filter(([, entry]) => !isExpired(entry))
|
||||
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
.slice(0, MAX_ENTRIES)
|
||||
)
|
||||
const keys: string[] = []
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
store.removeItem(STORAGE_KEY)
|
||||
for (let index = 0; index < store.length; index += 1) {
|
||||
const key = store.key(index)
|
||||
|
||||
return
|
||||
if (key?.startsWith(STORAGE_PREFIX)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
store.setItem(STORAGE_KEY, JSON.stringify({ entries, version: STORE_VERSION }))
|
||||
} catch {
|
||||
// Quota/private-mode failures: the journal is a recovery aid, not truth.
|
||||
}
|
||||
}
|
||||
const live: { key: string; updatedAt: number }[] = []
|
||||
|
||||
function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean {
|
||||
return now - entry.updatedAt > MAX_AGE_MS
|
||||
for (const key of keys) {
|
||||
let entry: InFlightTurnSnapshot | null = null
|
||||
|
||||
try {
|
||||
entry = JSON.parse(store.getItem(key) ?? '') as InFlightTurnSnapshot
|
||||
} catch {
|
||||
// Unparseable — prune below.
|
||||
}
|
||||
|
||||
if (!entry || typeof entry.updatedAt !== 'number' || isExpired(entry)) {
|
||||
store.removeItem(key)
|
||||
} else {
|
||||
live.push({ key, updatedAt: entry.updatedAt })
|
||||
}
|
||||
}
|
||||
|
||||
live.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
|
||||
for (const { key } of live.slice(MAX_ENTRIES)) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, like every other journal write.
|
||||
}
|
||||
}
|
||||
|
||||
function cloneMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
|
|
@ -419,15 +477,13 @@ function writeSnapshot(storedSessionId: string, state: JournalableSessionState):
|
|||
return
|
||||
}
|
||||
|
||||
const journal = loadStore()
|
||||
|
||||
journal.entries[storedSessionId] = {
|
||||
ensureHousekeeping()
|
||||
saveEntry(storedSessionId, {
|
||||
messages: tail,
|
||||
streamId: state.streamId,
|
||||
turnStartedAt: state.turnStartedAt,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
saveStore(journal)
|
||||
})
|
||||
}
|
||||
|
||||
/** Persist the running turn's visible tail (throttled), or clear the entry the
|
||||
|
|
@ -471,16 +527,15 @@ export function readInFlightTurnJournal(storedSessionId: null | string): InFligh
|
|||
return null
|
||||
}
|
||||
|
||||
const journal = loadStore()
|
||||
const entry = journal.entries[storedSessionId]
|
||||
ensureHousekeeping()
|
||||
const entry = loadEntry(storedSessionId)
|
||||
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isExpired(entry)) {
|
||||
delete journal.entries[storedSessionId]
|
||||
saveStore(journal)
|
||||
removeEntry(storedSessionId)
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -533,13 +588,6 @@ export function clearInFlightTurnJournal(storedSessionId: null | string): void {
|
|||
}
|
||||
|
||||
persistLatest.delete(storedSessionId)
|
||||
|
||||
const journal = loadStore()
|
||||
|
||||
if (!(storedSessionId in journal.entries)) {
|
||||
return
|
||||
}
|
||||
|
||||
delete journal.entries[storedSessionId]
|
||||
saveStore(journal)
|
||||
ensureHousekeeping()
|
||||
removeEntry(storedSessionId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $activeSessionId, $selectedStoredSessionId, $sessions, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionStates, $sessionTiles, closeSessionTile, publishSessionState } from '@/store/session-states'
|
||||
|
||||
/**
|
||||
* The closed-tile leak: gateway events keep publishing for sessions whose
|
||||
* surface is gone, and every parked transcript taxes every later publish (map
|
||||
* spread + the status projections run per entry per message delta). A settled
|
||||
* state nothing references must leave the map; everything a surface still
|
||||
* needs must stay.
|
||||
*/
|
||||
|
||||
const state = (storedId: string, patch: Partial<ReturnType<typeof createClientSessionState>> = {}) => ({
|
||||
...createClientSessionState(storedId),
|
||||
messages: [{ id: `${storedId}-m`, role: 'assistant' as const, parts: [{ type: 'text' as const, text: 'hi' }] }],
|
||||
...patch
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
$sessionStates.set({})
|
||||
$sessionTiles.set([])
|
||||
$sessions.set([])
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
describe('publish-time eviction', () => {
|
||||
it('evicts a settling session no surface references, keeping its unread dot', () => {
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeUndefined()
|
||||
// The settle transition still fired: the sidebar's unread marker landed.
|
||||
expect($unreadFinishedSessionIds.get()).toContain('stored-1')
|
||||
})
|
||||
|
||||
it('keeps a busy session with no surface — its background turn feeds the sidebar dot', () => {
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true, awaitingResponse: true }))
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps a needsInput session with no surface — the attention dot reads it', () => {
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false, needsInput: true }))
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps a settled session an open tile references, by runtime or stored id', () => {
|
||||
$sessionTiles.set([{ runtimeId: 'rt-1', storedSessionId: 'stored-1' }])
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
|
||||
// Mid-resume a tile holds only the stored id (runtime binding not patched
|
||||
// in yet) — that reference must count too.
|
||||
$sessionTiles.set([{ storedSessionId: 'stored-2' }])
|
||||
publishSessionState('rt-2', state('stored-2', { busy: true }))
|
||||
publishSessionState('rt-2', state('stored-2', { busy: false }))
|
||||
expect($sessionStates.get()['rt-2']).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the primary view\'s settled session', () => {
|
||||
$activeSessionId.set('rt-1')
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('always lands a FIRST publish — resume can publish before the surface points at the runtime', () => {
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeSessionTile eviction', () => {
|
||||
it('drops a settled session\'s state on close — no later publish may come', () => {
|
||||
$sessionTiles.set([{ runtimeId: 'rt-1', storedSessionId: 'stored-1' }])
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
|
||||
closeSessionTile('stored-1')
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a busy session\'s state on close — the background turn is still running', () => {
|
||||
$sessionTiles.set([{ runtimeId: 'rt-1', storedSessionId: 'stored-1' }])
|
||||
publishSessionState('rt-1', state('stored-1', { busy: true }))
|
||||
|
||||
closeSessionTile('stored-1')
|
||||
|
||||
expect($sessionStates.get()['rt-1']).toBeDefined()
|
||||
|
||||
// ... and its settle publish is what evicts it.
|
||||
publishSessionState('rt-1', state('stored-1', { busy: false }))
|
||||
expect($sessionStates.get()['rt-1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -40,7 +40,8 @@ import {
|
|||
$unreadFinishedSessionIds,
|
||||
lineageAliases,
|
||||
sessionMatchesStoredId,
|
||||
setActiveSessionStoredIdRotation
|
||||
setActiveSessionStoredIdRotation,
|
||||
setSessions
|
||||
} from './session'
|
||||
import { isSecondaryWindow } from './windows'
|
||||
|
||||
|
|
@ -191,6 +192,28 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
|
|||
}
|
||||
}
|
||||
|
||||
/** Is any surface on THIS window still holding the runtime — the primary view
|
||||
* or an open tile? (A tile mid-resume references by stored id only; its
|
||||
* runtime binding is patched in after `resumeTile` returns.) */
|
||||
function runtimeReferenced(runtimeId: string, storedSessionId: null | string): boolean {
|
||||
if (runtimeId === $activeSessionId.get()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return $sessionTiles.get().some(
|
||||
t => t.runtimeId === runtimeId || (storedSessionId !== null && t.storedSessionId === storedSessionId)
|
||||
)
|
||||
}
|
||||
|
||||
/** A state no surface needs anymore: its turn is over (not busy, not waiting
|
||||
* on the user) and neither the primary view nor any tile holds the runtime.
|
||||
* `needsInput` states stay — the sidebar's attention dot reads them. */
|
||||
function evictable(runtimeId: string, state: ClientSessionState): boolean {
|
||||
return (
|
||||
!state.busy && !state.needsInput && !state.awaitingResponse && !runtimeReferenced(runtimeId, state.storedSessionId)
|
||||
)
|
||||
}
|
||||
|
||||
/** Publish one session's state. Automatically fires transition side-effects
|
||||
* (watchdog arm/disarm, settle grace, unread marker, compression id rotation)
|
||||
* by diffing previous vs next — callers never need to manually call a
|
||||
|
|
@ -203,7 +226,17 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
|
|||
* ($workingSessionIds, $attentionSessionIds) and their subscribers
|
||||
* unnecessarily. The runtime-id→state cache (sessionStateByRuntimeIdRef)
|
||||
* is updated independently by the caller, so the visual path stays live
|
||||
* without the store churn. */
|
||||
* without the store churn.
|
||||
*
|
||||
* A settled state nothing references is EVICTED instead of republished:
|
||||
* gateway events keep flowing for sessions whose tile was closed mid-turn,
|
||||
* and parking each one's full transcript here forever is the leak that made
|
||||
* the app crawl after a day of tile use — every entry taxes every later
|
||||
* publish (map spread + the status-set projections). Transition side effects
|
||||
* still fire, so the closed session's settle keeps its unread dot. Only an
|
||||
* entry already in the map is evicted — a FIRST publish always lands, because
|
||||
* a resume can publish its idle state a beat before `$activeSessionId` /
|
||||
* the tile's runtime binding points at it. */
|
||||
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
|
||||
const current = $sessionStates.get()
|
||||
const prev = current[runtimeId] ?? null
|
||||
|
|
@ -212,6 +245,14 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
|
|||
return
|
||||
}
|
||||
|
||||
if (prev && evictable(runtimeId, state)) {
|
||||
handleTransition(prev, state, runtimeId)
|
||||
const { [runtimeId]: _dropped, ...rest } = current
|
||||
$sessionStates.set(rest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$sessionStates.set({ ...current, [runtimeId]: state })
|
||||
handleTransition(prev, state, runtimeId)
|
||||
}
|
||||
|
|
@ -753,6 +794,18 @@ export function closeSessionTile(storedSessionId: string) {
|
|||
}
|
||||
|
||||
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
|
||||
|
||||
// A settled session may never publish again, so the publish-time eviction
|
||||
// in publishSessionState can't reach it — drop its cached state here. A
|
||||
// BUSY one stays: its turn keeps streaming in the background, the sidebar
|
||||
// dot reads it, and settle evicts it. ⌘⇧T reopen re-publishes from the
|
||||
// wiring cache (resumeTile's warm path), so nothing is lost.
|
||||
const runtimeId = tile?.runtimeId
|
||||
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
|
||||
|
||||
if (runtimeId && state && evictable(runtimeId, state)) {
|
||||
dropSessionState(runtimeId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a DEAD tile — a persisted tile whose session no longer exists on the
|
||||
|
|
@ -869,10 +922,19 @@ $selectedStoredSessionId.listen(selected => {
|
|||
if ((import.meta.env.DEV || import.meta.env.VITE_PERF_PROBE === '1') && typeof window !== 'undefined') {
|
||||
;(window as unknown as Record<string, unknown>).__HERMES_SESSION_TILES__ = {
|
||||
close: closeSessionTile,
|
||||
drop: dropSessionState,
|
||||
open: openSessionTile,
|
||||
patch: patchSessionTile,
|
||||
publish: publishSessionState,
|
||||
/** Seed the recents list — models a populated sessions DB in perf runs. */
|
||||
seedSessions: (rows: SessionInfo[]) => setSessions(rows),
|
||||
sessions: () => $sessions.get(),
|
||||
states: () => $sessionStates.get(),
|
||||
tiles: () => $sessionTiles.get()
|
||||
tiles: () => $sessionTiles.get(),
|
||||
/** THE real gateway write path (wiring cache + journal + publish + view
|
||||
* sync), unlike `publish` which only touches the store. Perf scenarios
|
||||
* must drive this or they under-model streaming cost. */
|
||||
update: (runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState) =>
|
||||
sessionTileDelegate()?.updateSession(runtimeId, updater)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,6 +254,48 @@ export const sessionMatchesStoredId = (
|
|||
storedSessionId: string
|
||||
): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
|
||||
// Alias lookup, memoized per sessions-list reference. `lineageAliases` runs
|
||||
// per cached session state per status projection per message delta — an
|
||||
// O(sessions) scan there multiplies out to states × sessions × ~30Hz per busy
|
||||
// session, which is what made a populated recents list drag every stream. The
|
||||
// list is replaced wholesale (never mutated), so its reference is the cache key.
|
||||
type LineageRow = Pick<SessionInfo, '_lineage_root_id' | 'id'>
|
||||
const lineageIndexBySessions = new WeakMap<readonly LineageRow[], Map<string, string[]>>()
|
||||
|
||||
function lineageIndex(sessions: readonly LineageRow[]): Map<string, string[]> {
|
||||
const cached = lineageIndexBySessions.get(sessions)
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const index = new Map<string, string[]>()
|
||||
|
||||
const add = (key: string, value: string) => {
|
||||
const bucket = index.get(key)
|
||||
|
||||
if (!bucket) {
|
||||
index.set(key, [value])
|
||||
} else if (!bucket.includes(value)) {
|
||||
bucket.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
add(session.id, session.id)
|
||||
|
||||
if (session._lineage_root_id) {
|
||||
add(session.id, session._lineage_root_id)
|
||||
add(session._lineage_root_id, session.id)
|
||||
add(session._lineage_root_id, session._lineage_root_id)
|
||||
}
|
||||
}
|
||||
|
||||
lineageIndexBySessions.set(sessions, index)
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
/** Every id one conversation answers to: the id we were handed, plus the live
|
||||
* id and lineage root of each session it resolves to.
|
||||
*
|
||||
|
|
@ -262,25 +304,10 @@ export const sessionMatchesStoredId = (
|
|||
* same lineage after a compression. Publishing every alias lets those surfaces
|
||||
* keep using a plain membership test instead of each re-deriving lineage —
|
||||
* and getting it wrong, which reads as a running session going idle mid-turn. */
|
||||
export function lineageAliases(
|
||||
storedId: string,
|
||||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
|
||||
): string[] {
|
||||
const aliases = new Set([storedId])
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!sessionMatchesStoredId(session, storedId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
aliases.add(session.id)
|
||||
|
||||
if (session._lineage_root_id) {
|
||||
aliases.add(session._lineage_root_id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...aliases]
|
||||
export function lineageAliases(storedId: string, sessions: readonly LineageRow[]): string[] {
|
||||
// Every key is in its own bucket by construction, so the bucket IS the
|
||||
// alias set. Copied so no caller can mutate the shared index.
|
||||
return lineageIndex(sessions).get(storedId)?.slice() ?? [storedId]
|
||||
}
|
||||
|
||||
/** True when two ids name the same conversation across compression tip rotation. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue