Repository navigation
Desktop: returning to a large session re-reads up to 64 MiB of transcript and queues behind ~6s runtime-resource scans — hot switch is ~2.5x slower than the first open #5864
Description
Summary
Switching back to an already-opened 12,000-turn session (~220 MB durable transcript) takes 15.9-16.4s to first tail paint, while the first return to the same session takes 6.3-7.2s — a ~9s hot-switch penalty, 20-40x over the #4677 long-session budget of 800ms. Three independent causes stack:
- Nothing survives a switch. When the last observer of a settled session detaches, Desktop main closes the Host subscription, discards the transcript replica and deletes session state (
apps/desktop/src/main/runtime-host-session-observer.ts,#closeIfIdle→#closeState; its own comment names the cost: "letting the next viewer ask it to send everything a second time"). The renderer also rebuilds its transcript store on every activation (apps/desktop/src/renderer/app-shell-effects.ts), so every return is a full rebuild. - Every open re-reads history in 512 KiB pages until a 64 MiB budget trips. The chat surface opens transcripts in history mode (
apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts:117-135); main loopsreadOlderPage, onesession.transcript.pageRPC per page (runtime-host-session-observer.tsreset answer; budgets inapps/desktop/src/preload/transcript-contract.ts:25-27, page limits inpackages/runtime-host/src/protocol/session-transcript.ts:31-35). Measured: 130 sequential RPCs, p50 40 ms each, ~6.4 s, 88.8 MB over the wire per switch — the budget check runs after each page, so a full 512 KiB page is overshoot (64 → ~88 MB). The renderer store only commits when the reset answer completes, so nothing paints until the whole read finishes. - Hot switches additionally queue ~9-11 s behind runtime-resource scans. A
runtime.resource.query(list) poller — workbar shell-run hydration (apps/desktop/src/renderer/app-shell-effects.ts, retry backoff capped at 5 s → measured ~5.9 s period) — enters the per-session admission lane on every call (packages/runtime-host/src/server/runtime-resource-coordinator.ts) and holds it 5.8-6.6 s on a session whose ledger carries one transcript-derived run per converted turn (12,000 of them;packages/runtime/src/runtime-ledger-repair.ts). The re-open'ssubscription.open(handler itself: 4 ms) andsession.read_marker.set(handler: ~20 ms) queue behind these scans: measured lane waits 9.4-11.6 s per hot switch.
Why cold differs from hot: the first return lands before the poller has started (subscription.open served in 6 ms); every subsequent return queues behind the ~6 s scans. That is the entire cold/hot delta — the page-read phase itself is equally fast in both directions (130 pages, 6.3-7.0 s span both ways).
Measurements
Real Electron + real Host + real storage, instrumented worktree, 8 switches per run, three independent runs (Windows 11, commit 46e457c; line numbers above refer to that tree):
| Segment | Cold first return | Hot switch (median) |
|---|---|---|
| click → first frame | 4 ms | 38 ms |
subscription.open |
6 ms | 9.4-9.8 s (handler 4 ms; rest is lane wait) |
| 130-page history read (first→last page span) | 7.0 s | 6.3-6.8 s |
| page p50 / inter-page gap p50 | 40 ms / 7 ms | 39 ms / 7 ms |
| last page → tail visible | 56 ms | 48-448 ms |
| click → tail total | 6.3-7.2 s | 15.9-16.4 s |
- Per-switch wire volume: 88.8 MB host→client; ~190 request/response pairs per LARGE↔SMALL round trip.
- Injecting +30 ms one-way latency multiplies the paging phase as predicted (+11.5 s ≈ 190 pairs × 60 ms) — the paging phase is round-trip-count bound, not bandwidth bound (88.8 MB / 6.4 s ≈ 14 MB/s). The cold/hot delta shrinks under injected latency (7 → 3.5 s), consistent with the delta being queueing (waits overlap with RTT sleeps), not frame count.
- Handler-level ablation: with env-gated timing on
#open/#setReadMarker/gate, canonical 1-2 ms, highWater 0 ms (SELECT MAX(ordinal)), bootstrap 1-4 ms, marker handler 19-27 ms — handlers are fast; the waiting is all lane queueing (gate logs nameruntime.resource.queryas the holder).
Fix directions (options, not prescriptions)
- a) Retain subscription + replica for recently-viewed sessions (idle-grace LRU) instead of teardown.
- b) Keep rendered history client-side across switches (the
cached:generation machinery already does this for Host-gone recovery) so a return fetches only the delta past the cached watermark. - c) Make the reset answer tail-first and progressive: paint newest turns from the subscription bootstrap immediately, stream older pages behind it — instead of committing only when
readycompletes. - d) Stop
runtime.resource.queryfrom scanning one run per converted turn (index the tail / cap the scan), and/or keep page reads from sharing the lane a poll can stall for seconds. - e) Check the history budget before fetching a page (today it overshoots by a full 512 KiB page, 64 → 88 MB).
Reproduction
Seed the chat-large-history e2e fixture (MAKA_E2E_FIXTURE=chat-large-history, 12k+4k+4k-turn sessions), open the deep session, switch away and back; time click → last [data-turn-id]. First return ≈ 7 s, subsequent returns ≈ 16 s. Note the first-ever open also pays a one-time transcript-ledger materialization (minutes at 12k turns) — use a readiness window ≥ 900 s.
Measured on Windows 11 at 46e457c in an instrumented throwaway worktree (harness/taps not part of the tree; happy to share tap logs and per-round tables). Unverified residuals: the poller's exact refire trigger (failure-retry vs resync replay); the one-time first-open materialization cost is a separate budget question.
Activity
ggbdpq commentedon Sep 30, 2026
Overlap intel from our diagnostics in this issue: #5712 (Fixes #5627) covers a large part of this issue's mechanism, based on reading its current diff (it is still open and conflicted, so this is code-reading, not measurement):
listShellRunUpdates/getSessionUpdatenow return own-state records for ordinary Sessions without reading transcript projection history (its new test is literally named "root resource reads preserve own states without reading transcript history"). If that lands, eachruntime.resource.queryshould shrink from ~6 s of lane occupancy to milliseconds, which also dissolves most of the queueing amplifier behind the 9.4–9.8 s hot switch.What #5712 does not cover, per its stated scope ("no new persistent table, index, projection, cross-page cache, or worker") and its file list (no polling/admission files):
closeIfIdle), so every return still pays subscription re-establishment plus a fresh tail read from zero.resource.querycan monopolize the admission lane thatsubscription.openandread_marker.setmust traverse — even a fast scan does not make the lane shared-traffic-safe against future slow queries.Proposal: once #5712 merges, we can re-run the hot-switch rig from this issue's measurements against it and post a fresh residual-gap baseline (same fixture, same anchors, before/after in one comment). If a meaningful gap remains, we'd like to pick up one of the three leftovers above — our preference order would be the budget pre-check (smallest blast radius), then the subscription-lifecycle / retained-replica question (largest, and it needs a direction call from maintainers since it changes closeIfIdle semantics). Does that sequencing work, or would you rather we scope one of these now?
Adarsh-Me commentedon Oct 1, 2026
take
Adarsh-Me commentedon Oct 2, 2026
@ggbdpq — verified against #5712's 59 files: it rewrites
transcript-contract.ts,desktop-transcript-range-store.tsandruntime-host-session-observer.ts, so (e) and thecloseIfIdleteardown are inside its blast radius; I won't write either on main. It touches no admission/polling file, so the lane behind the 9.4 ssubscription.openwait is free — I'd take that pending a committer's call on the semantics.ggbdpq commentedon Oct 2, 2026
Confirmed on all three counts — I re-checked #5712's file list just now: 59 files, all three you name are in it (
transcript-contract.ts,desktop-transcript-range-store.ts,runtime-host-session-observer.ts), and zero admission/polling/workbar files. Your blast-radius read matches mine.Division of labor works for me:
runtime.resource.querylane occupancy behind the 9.4 ssubscription.openwait. Agreed it's the highest-leverage single point left, and agreed it needs a committer's call on semantics first: a faster scan alone doesn't make lane sharing safe, which is the trap noted in my comment above.closeIfIdleteardown: hold for perf(desktop): bound transcript work on session switches #5712 to land, then re-baseline — writing either on main now just creates rebase churn against a 59-file rewrite of the exact files involved.One offer: since I filed this issue, the before-numbers (130 pages / 88.8 MB full re-read, per-query lane occupancy, the 9.4–9.8 s hot-switch penalty) came from a local measurement rig. When your patch is ready I can re-run the same rig unchanged as the after-baseline, so before/after stay same-method — or if you'd rather bring your own harness, mine is happy to cross-check.
Adarsh-Me commentedon Oct 2, 2026
@ggbdpq pre-check on main:
#queryenterssessionAdmission.runper call (runtime-resource-coordinator.ts:306) with zero in-flight dedup, so N concurrent polls each queue a full scan. Alsoapp-shell-effects.tsis 265 lines now — the poller isuse-workbar-controller.ts:483, capped 2 s, not 5. Before I write the lane half: is single-flight dedup the semantics you meant?Adarsh-Me commentedon Oct 2, 2026
@ggbdpq correction, measured: on
ab5996breadRuntimeHostResourcespages sequentially throughcollectStablePages, so the 63-request in-flight cap doesn't bind. Against a stub connection, one mid-paging revision change discards every fetched page and restarts atlist_start(4 requests → 7); churning every page gives 17 requests, 8 restarts, thenCatalogReadError: unstable. The amplifier is that restart policy, not coordinator dedup.ggbdpq commentedon Oct 2, 2026
Thanks for the two corrections — I re-verified all three against
ab5996bbefore answering: (1)#queryenterssessionAdmission.runper call (runtime-resource-coordinator.ts:306); (2)SessionAdmissionGate's#tailsis a per-session FIFO — it serializes but never coalesces, so identical concurrent queries each run a full scan (your "zero in-flight dedup" holds); (3)collectStablePagesrestarts fromreadFirst()on any mid-paging revision change with capped backoff, ending inCatalogReadError: unstable— your stub numbers are the measurements of record. On our side, the "~5.9 s cycle" in the issue body was observed wall-clock including scan time; your 2 s cap fromuse-workbar-controller.tsis the code-level truth, so treat mine as superseded.To your question directly: no — single-flight dedup is not the semantics we meant, and I'd argue it isn't the fix your own data points to. The issue deliberately named the symptom, not the mechanism: the user-visible penalty is an interactive return-to-session read queueing behind a periodic scan on the shared per-session lane. Your restart-policy measurement supersedes my dedup-adjacent framing, because dedup only removes redundant lane arrivals — each surviving scan still pays the full restart multiplier under churn, and with a single poller (2 s-capped retry backoff) concurrent arrivals are already bounded. Dedup reads like optional hygiene, not the fix.
The semantics I'd actually endorse, in the order your data supports:
Happy to re-run the same rig for before/after when your lane patch is ready. One honest caveat per my issue's style: everything above is code-reading on
ab5996b, no runtime probe — your stub measurements are the only measured numbers in this thread so far.