Skip to content

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

Open

Description

@ggbdpq

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:

  1. 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.
  2. 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 loops readOlderPage, one session.transcript.page RPC per page (runtime-host-session-observer.ts reset answer; budgets in apps/desktop/src/preload/transcript-contract.ts:25-27, page limits in packages/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.
  3. 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's subscription.open (handler itself: 4 ms) and session.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 name runtime.resource.query as 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 ready completes.
  • d) Stop runtime.resource.query from 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

  1. ggbdpq commented on Sep 30, 2026

    @ggbdpq
    ContributorAuthor

    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):

    • Layer 2 (full re-read on return, 130 pages / 88.8 MB): its bounded range store and reader-position-following reads target exactly this — its own table reports 10.946 s → 663 ms for a very large idle local return.
    • Layer 3, scan-cost half (the ~5.9 s polling cycle monopolizing the per-session admission lane via a 12k-conversion-run scan): listShellRunUpdates/getSessionUpdate now 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, each runtime.resource.query should 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):

    • Layer 1: the last observer leaving tears down the subscription (closeIfIdle), so every return still pays subscription re-establishment plus a fresh tail read from zero.
    • The polling cadence/refire design itself (5 s retry ceiling → ~5.9 s observed cycle) and the fact that any slow resource.query can monopolize the admission lane that subscription.open and read_marker.set must traverse — even a fast scan does not make the lane shared-traffic-safe against future slow queries.
    • The 64 MiB budget's post-page overrun (88.8 MB actually read against a 64 MiB budget, because the check runs after each page completes).

    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?

  2. Adarsh-Me commented on Oct 1, 2026

    @Adarsh-Me
    Contributor

    take

  3. Adarsh-Me commented on Oct 2, 2026

    @Adarsh-Me
    Contributor

    @ggbdpq — verified against #5712's 59 files: it rewrites transcript-contract.ts, desktop-transcript-range-store.ts and runtime-host-session-observer.ts, so (e) and the closeIfIdle teardown 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 s subscription.open wait is free — I'd take that pending a committer's call on the semantics.

  4. ggbdpq commented on Oct 2, 2026

    @ggbdpq
    ContributorAuthor

    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:

    • You: the admission-lane / polling-rhythm half — the ~5.9 s periodic refire and the ~6 s runtime.resource.query lane occupancy behind the 9.4 s subscription.open wait. 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.
    • (e) and the closeIfIdle teardown: 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.

  5. Adarsh-Me commented on Oct 2, 2026

    @Adarsh-Me
    Contributor

    @ggbdpq pre-check on main: #query enters sessionAdmission.run per call (runtime-resource-coordinator.ts:306) with zero in-flight dedup, so N concurrent polls each queue a full scan. Also app-shell-effects.ts is 265 lines now — the poller is use-workbar-controller.ts:483, capped 2 s, not 5. Before I write the lane half: is single-flight dedup the semantics you meant?

  6. Adarsh-Me commented on Oct 2, 2026

    @Adarsh-Me
    Contributor

    @ggbdpq correction, measured: on ab5996b readRuntimeHostResources pages sequentially through collectStablePages, 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 at list_start (4 requests → 7); churning every page gives 17 requests, 8 restarts, then CatalogReadError: unstable. The amplifier is that restart policy, not coordinator dedup.

  7. ggbdpq commented on Oct 2, 2026

    @ggbdpq
    ContributorAuthor

    Thanks for the two corrections — I re-verified all three against ab5996b before answering: (1) #query enters sessionAdmission.run per call (runtime-resource-coordinator.ts:306); (2) SessionAdmissionGate's #tails is 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) collectStablePages restarts from readFirst() on any mid-paging revision change with capped backoff, ending in CatalogReadError: 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 from use-workbar-controller.ts is 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:

    1. Bound the restart cost itself — make the scan revision-tolerant: continue paging and re-verify the ending digest instead of discarding everything, or resume from the last stable cursor. That attacks the measured amplifier (17 requests / 8 restarts → 0) at its source. Different site from perf(desktop): bound transcript work on session switches #5712's range store, so no collision there.
    2. Keep interactive reads off the scan's queue — whether that's a second lane, priority admission, or something else is exactly the committer-semantics question you flagged: is per-session strict FIFO admission a correctness invariant, or can same-connection reads overtake a scan?

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions