From 086a2c3e4b5c467ff083ac5607cd9a0ce5462329 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 7 Jul 2026 16:04:00 +0200 Subject: [PATCH] perf(sessions): cap warm session runtimes to bound /sessions memory growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /sessions sidebar eagerly warmed a full runtime (AIChatManager + loaded chat history + an IndexedDB connection) for every visible session, so the page's mounted weight grew linearly (O(number of non-archived in-family sessions)) and never released as sessions accumulate over time. Cap the warm runtimes with an MRU (the active session + up to MAX_WARM_RUNTIMES) and dispose the rest, mirroring the existing preview-tab and editor MRU caps. Sessions holding volatile state — a mid-stream generation, a pending tool confirmation, or unsent composer text — are kept warm so nothing is cancelled or lost (protect-not-persist; the composer draft lives in AIChatInput local state and isn't persisted). Cold (evicted) sessions show the neutral status dot with no unread badge until reopened. Measured ~1.5-2x the transcript size per resident session; the change converts O(N) growth into O(1). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/sessions/SessionPicker.svelte | 22 +++---- .../components/sessions/SessionWrapper.svelte | 15 ++++- .../sessions/sessionRuntime.svelte.ts | 59 +++++++++++++++++++ .../sessions/sessionUnread.svelte.ts | 3 +- .../(root)/(logged)/sessions/+page.svelte | 25 ++++++-- 5 files changed, 103 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index a781b6deb1..9363375842 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -36,7 +36,6 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import Toggle from '$lib/components/Toggle.svelte' import { - getOrCreateRuntime, getRuntime, getSessionChatStatus, removeSession, @@ -80,7 +79,9 @@ // read manager.instructions directly (not the derived chat status) // so the draft cue still shows during streaming/needs-confirmation — // those override the icon slot but shouldn't hide the fact that the - // user has unsent text in this session. + // user has unsent text in this session. A cold (evicted) session has + // no live runtime, so no cue — but a session with an unsent draft is + // kept warm (see evictColdRuntimes), so this stays accurate. function hasDraft(session: Session): boolean { const rt = getRuntime(session.id) return !!rt && rt.manager.instructions.trim().length > 0 @@ -269,17 +270,12 @@ } }) - // Eagerly create a runtime per VISIBLE session so the status dot reflects - // the persisted chat (last message, pending confirmation, etc.) without - // requiring the user to open the session first. Sessions outside the - // current workspace scope are left cold to avoid opening IDB connections - // for unrelated work. - $effect(() => { - for (const session of visibleSessions) { - getOrCreateRuntime(session) - } - }) - + // The picker no longer eagerly warms a runtime per visible session — that made + // mounted weight grow unbounded with the family size. Runtimes are created on + // activation and MRU-capped (see sessionRuntime), so a cold session shows the + // neutral status dot and no unread badge until it's opened; sessions with + // volatile state (pending confirmation / unsent draft / streaming) are kept + // warm so their live dot stays correct. function isUnavailableFork(session: Session): boolean { return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id) } diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 8161349656..74e94350dc 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -53,7 +53,12 @@ syncWorkspaceTo, type SessionTarget } from './sessionState.svelte' - import { editorWarmIds, getOrCreateRuntime, removeSession } from './sessionRuntime.svelte' + import { + editorWarmIds, + getOrCreateRuntime, + removeSession, + setSessionDraftFlag + } from './sessionRuntime.svelte' import { goto } from '$lib/navigation' import { base } from '$app/paths' import { slide } from 'svelte/transition' @@ -533,7 +538,13 @@ hideModeSelector wideLayout initialInstructions={restoredDraftPrompt} - onDraftChange={(text) => queueTransientDraftPrompt(sessionId, text)} + onDraftChange={(text) => { + queueTransientDraftPrompt(sessionId, text) + // Keep the runtime warm while the composer holds unsent text — the + // draft is local to AIChatInput and isn't persisted, so eviction + // would lose it (see setSessionDraftFlag). + setSessionDraftFlag(sessionId, text.trim().length > 0) + }} forceDisabled={isUnavailable || !!session.archived} forceDisabledMessage={isUnavailable ? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.' diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 3c6ea7cdb8..674dbabbaf 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -738,6 +738,8 @@ export function disposeRuntime(sessionId: string) { runtime.manager.cancel('runtime disposed') runtime.manager.historyManager.close() runtimes.delete(sessionId) + runtimeWarmIds.delete(sessionId) + sessionsWithDraft.delete(sessionId) } export function listRuntimes(): SessionRuntime[] { @@ -800,6 +802,63 @@ export function promoteEditorWarm(sessionId: string): void { } } +// MRU of session ids whose runtime should stay warm. A full SessionRuntime +// (AIChatManager + loaded history + IDB connection) is ~tens of MB, so warming +// one per visible session made the page scale linearly with the family size. +// We keep the active session + this MRU + any session with an in-flight stream +// warm (see evictColdRuntimes) and dispose the rest; a cold session re-warms on +// activation from its persisted chat + snapshot. +const MAX_WARM_RUNTIMES = 3 +const runtimeWarmIds = new SvelteSet() + +// Sessions whose composer holds unsent text. The draft lives in AIChatInput's +// local state (not manager.instructions — that only carries programmatic +// prompts), and it isn't persisted, so a session with a draft must be kept warm +// or the text is lost. SessionWrapper reports changes via setSessionDraftFlag as +// the user types; read at eviction time (see hasVolatileState). +const sessionsWithDraft = new Set() + +export function setSessionDraftFlag(sessionId: string, hasDraft: boolean): void { + if (hasDraft) sessionsWithDraft.add(sessionId) + else sessionsWithDraft.delete(sessionId) +} + +export function promoteRuntimeWarm(sessionId: string): void { + runtimeWarmIds.delete(sessionId) + runtimeWarmIds.add(sessionId) + while (runtimeWarmIds.size > MAX_WARM_RUNTIMES) { + const oldest = runtimeWarmIds.values().next().value + if (oldest === undefined) break + runtimeWarmIds.delete(oldest) + } +} + +// Live state that would be lost by eviction, so the runtime must stay warm even +// when it falls out of the MRU. We don't persist these (that machinery isn't +// worth its complexity — see the perf memo), so keeping the session warm is how +// its picker dot stays right and its unsent draft survives: +// - a mid-stream generation (disposeRuntime cancels it), +// - a pending tool confirmation (the amber "needs you" dot is read live), +// - unsent composer text (there is no persisted copy to restore from). +function hasVolatileState(runtime: SessionRuntime): boolean { + const m = runtime.manager + if (m.loading) return true + if (sessionsWithDraft.has(runtime.sessionId)) return true + const last = m.displayMessages[m.displayMessages.length - 1] + return last?.role === 'tool' && !!last.needsConfirmation +} + +// Dispose every warm runtime that isn't the active session, in the warm MRU, or +// holding volatile state. Idle sessions that fall out of the MRU go cold; their +// picker dot reverts to the neutral icon until reopened. +export function evictColdRuntimes(activeId: string | undefined): void { + for (const runtime of runtimes.values()) { + const id = runtime.sessionId + if (id === activeId || runtimeWarmIds.has(id) || hasVolatileState(runtime)) continue + disposeRuntime(id) + } +} + // Register the global open_preview tool handler once at module load. It // dispatches to the *calling* session (sessionId threaded from the tool ctx), // falling back to the UI-active session only when none was passed — so a diff --git a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts index 9582c713e2..7f81771abd 100644 --- a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts @@ -18,7 +18,8 @@ export function markSessionSeen(sessionId: string, count: number) { } // Number of unread messages for a session. Undefined / unloaded runtime -// returns 0 — until messages are hydrated we don't know what's new. +// returns 0 — a cold (evicted / not-yet-warmed) session has no live message +// count to compare against, so its badge only appears once it's warmed. export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number { if (!runtime) return 0 const seen = sessionState.sessions.find((x) => x.id === sessionId)?.lastSeenCount ?? 0 diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 230e7c9964..f8cc8cc349 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -36,10 +36,12 @@ import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte' import { userWorkspaces, workspaceStore } from '$lib/stores' import { + evictColdRuntimes, getOrCreateRuntime, getRuntime, listRuntimes, - promoteEditorWarm + promoteEditorWarm, + promoteRuntimeWarm } from '$lib/components/sessions/sessionRuntime.svelte' import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' @@ -119,19 +121,32 @@ // deep links the same way they react to picker clicks. selectSession(session.id) getOrCreateRuntime(session) + // Mark this session most-recently-used so the eviction pass keeps it (and + // the last few) warm while disposing the rest. + promoteRuntimeWarm(session.id) }) }) - // Warm = sessions with a live runtime. The picker eagerly creates runtimes - // for its visible sessions, so this tracks whatever it shows. Keeping warm - // chats mounted (stacked, visibility-toggled) preserves their scroll/draft - // state across switches. + // Warm = sessions with a live runtime (active + MRU + any mid-stream session — + // see the eviction effect). Keeping warm chats mounted (stacked, visibility- + // toggled) preserves their scroll/draft state across switches, and capping the + // set bounds the page's mounted weight regardless of family size. const warmSessions = $derived( listRuntimes() .map((r) => sessionState.sessions.find((s) => s.id === r.sessionId)) .filter((s): s is NonNullable => s != null) ) + // Dispose runtimes that fell out of the warm set. Re-runs when the active + // session changes, and — by reading each runtime's `loading` — when a + // background stream finishes, so a session kept warm only because it was + // streaming gets evicted once it settles (unless it's back in the MRU). + $effect(() => { + const activeId = activeSession?.id + for (const rt of listRuntimes()) void rt.manager.loading + untrack(() => evictColdRuntimes(activeId)) + }) + // Promote the active session in the LRU. Mutations untracked so the effect // only re-runs when activeSession changes, not on its own writes. $effect(() => {