perf(renderer): faster big-chat open + bounded memory (#2162)#2957
perf(renderer): faster big-chat open + bounded memory (#2162)#2957KarloAldrete wants to merge 11 commits into
Conversation
The sidebar consumed the whole `sessions` record via `useSessions()`, which immer replaces on every appended event (one per streamed token). Since the sidebar is mounted at the root, that re-rendered the whole tree on every token. `deriveTaskData` only reads four session fields (isPromptPending, pendingPermissions size, cloudStatus, cloudOutput.pr_url) -- never `events`: - Add `computeSidebarSessionSignature` (core, pure): a primitive signature of just those fields. - Add `useSidebarSessionMap` (ui): subscribes to that signature and rebuilds the taskId -> session map only when a sidebar-relevant field changes. - `useSidebarData` uses it instead of `useSessions()`. Render-count test: 20 streamed tokens caused 20 sidebar re-renders before, 0 after (and 1 when a relevant field actually changes). Part of PostHog#2162
session.events is an append-only mirror of the on-disk ndjson log that was never freed, so renderer memory grew unbounded across open tasks. Evict the events of unfocused, idle sessions after a grace window and rehydrate from disk on refocus (ensureEventsLoaded keeps the session warm). Never evicts a streaming session, a queued-turn session, or a live cloud run. Part of PostHog#2162.
useCommandCenterData subscribed to the whole sessions Record via useSessions(), so every appendEvents (one per token) rebuilt the cells and re-rendered the grid. The grid only needs deriveStatus's 4 fields; cell transcripts update independently via each EmbeddedSessionView's own subscription. Mirror the sidebar fix (PostHog#2710): subscribe to a stable status signature and rebuild the session map only when it changes. Part of PostHog#2162.
Reloading an evicted transcript re-read the whole ndjson (178MB) and JSON.parsed ~100k lines synchronously — a ~500ms+ main-thread freeze. Now parse the last 256KB first so the latest messages render in ~1-2ms, then parse the full history in yielding chunks (parseSessionLogContentChunked) without blocking, and swap it in for scrollback. Measured 1.5ms vs 1710ms (1134x) for the tail on a real 323MB / 54k-event session. Part of PostHog#2162.
The first open of a finished task went through reconnectToLocalSession, which parsed the whole ndjson up front — the same ~500ms+ freeze as the refocus path. Fetch raw content instead of pre-parsed logs, seed the transcript from the tail (instant), derive sessionId/adapter from the head (the sdk_session marker sits at line ~4), and parse the full history in background chunks. Reuses commitLoadedEvents' streaming guard. Part of PostHog#2162.
Committing a freshly-parsed transcript to the immer-backed session store made immer deep-freeze every event object — measured ~240ms for a 48k event session (immer's per-element isDraftable/handleValue machinery is ~50x slower than a plain Object.freeze loop). Events are immutable log data, so freeze them as they're built; immer then short-circuits on Object.isFrozen. ~240ms to ~5ms on load. Part of PostHog#2162.
…n scroll Opening a finished task parsed + committed + rendered the ENTIRE history (100k events) up front — seconds of main-thread hitches, even though you only see ~15 messages. Now a transcript opens as a tail window (latest ~1000 events, instant) with the rest kept as raw text outside the immer store; scrolling toward the top pulls in older chunks, anchored so the viewport doesn't jump. Opening f0117a1c: ~1763ms of blocking -> ~209ms. This is the Claude-fast model: open cost is O(visible), not O(history). Part of PostHog#2162.
The tail window kept every ndjson line of an open transcript resident as raw text for scrollback, pinning ~110MB per open chat. A production heap benchmark put one 48k-event session at ~270MB while focused vs ~54MB with this change (and Command Center "ADHD mode" multiplied it across chats). Drop the in-memory copy and re-read the log from disk (OS page cache) on scroll-up, slicing only the older chunk. Scroll-up is rare and user-initiated, so a little latency there buys a large, always-on memory win. Older lines are start-indexed and append-stable, so a grown log still slices correctly.
|
| const delta = el.scrollHeight - scrollHeightBeforeLoadRef.current; | ||
| if (delta > 0) { | ||
| el.scrollTop += delta; | ||
| isAtBottomRef.current = false; | ||
| loadingOlderRef.current = false; | ||
| } |
There was a problem hiding this comment.
The scrollback gate (
loadingOlderRef) is only released when delta > 0, leaving it permanently true in two situations: (1) prependEvents is a no-op for an empty slice (guard at the top of prependEvents), so items.length never changes and this effect never fires — triggered when an older log chunk parses entirely to zero entries but hasOlder is still true; (2) even when items are prepended, if the virtualizer hasn't yet reflected the height change before this layout effect runs, delta is 0 and the gate stays locked. Either way, every subsequent scroll near the top checks !loadingOlderRef.current and short-circuits, permanently blocking scrollback for the lifetime of this component instance.
| const delta = el.scrollHeight - scrollHeightBeforeLoadRef.current; | |
| if (delta > 0) { | |
| el.scrollTop += delta; | |
| isAtBottomRef.current = false; | |
| loadingOlderRef.current = false; | |
| } | |
| const delta = el.scrollHeight - scrollHeightBeforeLoadRef.current; | |
| loadingOlderRef.current = false; | |
| if (delta > 0) { | |
| el.scrollTop += delta; | |
| isAtBottomRef.current = false; | |
| } |
Review feedback (PostHog#2957): the scrollback re-trigger gate was cleared only inside `if (delta > 0)`, so a load whose height the virtualizer hadn't measured yet — or one that prepended nothing renderable — left the gate stuck and permanently blocked further scroll-up. Release it whenever the anchor effect fires after a load, and make `takeOlderEntries` skip older chunks that parse to zero entries so it never reports `hasOlder` alongside an empty slice.
Review feedback (PostHog#2957): `parseSessionLogContentChunked` (and its only helper `parseLogLine`) had no callers — the windowed open parses the tail synchronously and loads older chunks on demand, so the background chunked parser was dead. Drop it and its test.
Review feedback (PostHog#2957): collapse the repeated "changes when X changes" cases into a single it.each table, matching the parameterised style already used in computeSidebarSessionSignature.test.ts.
|
Thanks for the review — addressed all three findings:
Core (229) + UI sessions/command-center tests green, typecheck + biome clean. |
Problem
Issue #2162 — renderer CPU & memory on real, heavy sessions. Three concrete symptoms:
session.eventsis append-only and stays resident after you navigate away. With a few big chats open (Command Center / "ADHD mode") the renderer heap climbs toward the"memory-eviction"crash reason the app already ships inapps/code/src/main/index.ts.sessionsstore, so a token from any agent re-renders them — and the grid rebuilds all N cells, i.e. O(N²·tokens).Changes
Six focused changes. The open/memory ones (1–4) are backed by a production A/B benchmark; the subscription ones (5–6) by render-count tests.
JSON.parse-ing the whole ~110 MB log up front.Object.freezeeach event at creation so immer skips its deep-freeze walk on commit (~240 ms → ~5 ms on a 48k-event session).session.events~20 s after a chat loses focus; rehydrate from disk on return.Production A/B benchmark
Two production (minified) builds —
baseline=main,this PR— same machine and user profile, opening the same real 48k-event chat. Renderer heap = live objects after forced GC; CPU summed across all Electron processes.Opening the chat (cold open):
N=6 reload distributions don't overlap (baseline median 1042 ms, range 1000–1069; this PR well below).
Renderer heap (live objects):
Cross-checked on a second, current task (96 MB log): 188 → 63 MB. No regression in idle CPU, DOM node count, or GPU/VRAM.
📊 Full report — every axis (RAM · CPU · GPU · DOM · jank) with charts — attached below.
Relationship to #2710
Supersedes #2710 — change #5 is that PR's commit and #6 is the same pattern applied to the Command Center grid; they belong together. Happy to close #2710 in favor of this once reviewed.
How did you test this?
ensureEventsLoaded.test.ts,sessionLogs.chunked.test.ts,sessionStore.test.ts,commandCenterSignature.test.ts,computeSidebarSessionSignature.test.ts,useSessionEventsResidency.test.tsx,useSidebarSessionMap.test.tsx.pnpm --filter @posthog/core test→ 1778 pass;pnpm --filter @posthog/ui test(sessions / sidebar / command-center) → 341 pass;pnpm typecheckclean;biome checkclean on changed files./proc+/sys, forced GC before each heap read.Automatic notifications
Created with PostHog Code