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:
brooklyn! 2026-08-10 05:45:43 -05:00 committed by GitHub
parent c08b086013
commit 3139a30e52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 519 additions and 124 deletions

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

@ -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()
})
})

View File

@ -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-idstate 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)
}
}

View File

@ -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. */