Files
nucleic/docs/MAIN_THREAD_PERFORMANCE_PLAN.md
T

21 KiB
Raw Blame History

Main-Thread Performance Plan

Status (2026-07-31)

  • Item 2 — done. SessionUIProjector (one actor per live session) reduces each controller stream off-main and commits batched deltas (~11/s open, ~3/s background); wired through AppStore.observe/ingestUI. Contract tests: SessionUIProjectorTests.
  • Item 3 — done. SessionController.ingest stamps updatedAt only for user-visible activity or a status transition; lastSeq alone advances on streaming deltas, so summaries are value-identical during a token burst and upsertSummary's no-op guard stops the sidebar/Recents/dashboard invalidation at the source. Regression test: streamingDeltasDoNotRestampUpdatedAt. (The ID-keyed summary index was judged not worth the churn once upserts dropped to one per coalesced batch.)
  • Item 4 — done, including off-main projection. IncrementalTranscriptProjection (stable-prefix sealing, live-tail refold, full-fold fallback on any doubt) is owned by a per-open-chat TranscriptProjectionWorker actor. Projection, display filtering, auth cleanup, and segment-range construction all run away from the main actor; SwiftUI reads only the worker's immutable cached result. A worker is replaced on selection, so an abandoned giant fold cannot serialize the newly opened chat behind it. During startup, the bounded tail preview remains interactive while authoritative full-history projection completes. Prefix-equivalence suite: IncrementalTranscriptProjectionTests; preview-to-full worker replacement is covered by TranscriptRenderSegmenterTests. Extended 2026-07-28: (a) the store's throttled summary pass (AppStore.regenerateToolGroupSummaries and its staleness guards) now derives its units through the same projector (summarizableUnits(events:), a second lazily-primed consumer of the shared seam) instead of refolding the whole open transcript per invocation; (b) the per-read cost during a long subagent run is bounded — the tail's partition is cached and appended to (TailCache), each open scope's closed children seal under a per-scope projector (ScopeChildProjection, recursive, cursor-verified against late events into result-fenced scopes), and a pinned watermark records why it can't advance (WatermarkHint) so quiet ticks validate only the new events instead of rescanning the unsealed region. Previously the open Task's whole scope refolded on every display tick — O(scope²) across the run. Extended 2026-07-31/2026-08-01: render rows use stable two-row batches (reduced from twelve, then six, after cold-open tuning). Direct LazyVStack virtualization was rejected after completed transcripts demonstrated estimate corrections while scrolling: mixed-height Markdown/tool batches moved the document even with no live output. Extended 2026-08-01: histories beyond 96 projected rows now use those batches as native macOS List rows. Its AppKit-backed reuse removes off-screen SwiftUI/TextKit hierarchies while retaining resolved variable row heights; shorter histories keep exact eager geometry. Segment ranges append incrementally, and Equatable boundaries let settled subtrees and sizes be reused while only the live batch changes. Initial/passive following keys off the rendered tail revision (so autoship notes and other log entries count as the end); the scroll surface's first appearance is gated by AppStore.openTranscriptAvailableSessionID: during cold startup a reverse 48-event/128-KiB cursor can publish the newest canonical tail before the controller's full decode completes; openTranscriptReadySessionID remains the separate authoritative edge raised after the ordered snapshot→history pull reaches the controller's known tail. Unread-marker writes, Nash viewer hydration, and whole-history tool-summary bookkeeping follow that readiness edge instead of withholding the canvas. Continuous scroll geometry is quantized into three threshold regions before it reaches the main actor. Long-history top-level Markdown warms continuously newest-to-oldest on two bounded utility-QoS lanes. This replaces one detached task, signpost, actor round-trip, and set mutation per two-row segment. Native rows have local readiness state: a visible row performs an independent user-initiated cache fill while the background walk keeps advancing, and completing it does not invalidate the root transcript or every other List row. Recycling cancels only that row's cache fill; there is no cancel/restart priority barrier. Off-main projection likewise continues during interaction/deceleration, while canceled obsolete preview revisions are rejected before they can queue another whole-history fold. After the first real tail chunk exists, an explicit unanimated pin targets the fixed sentinel below all prose, autoship/lock/status rows, and bottom padding rather than relying on List's early estimated bottom. Every segment has a cheap row-count length and an immediate transparent slot. A derived, per-session binary sidecar retains measured vertical lengths by content width/filter/revert epoch, but loads opportunistically and never gates first text. Its in-memory form is dictionary-backed and unobserved: height lookup/recording is O(1), and measuring a row no longer writes root @State or rebuilds the whole List. Cold misses use content-derived estimates computed with projection off-main, so a multi-page response reserves roughly its actual canvas instead of 48 points and no longer makes the document grow repeatedly during a fast scroll. Unmounted slots draw only a constant-size pair of neutral bars. Placeholder and real content are exclusive, unanimated branches—no fake-text typesetting, gradient mask, blur, infinite sweep, or simultaneous focused/unfocused layers. Collapsed tool/subagent descendants remain outside the mount path and join the background-priority durable snapshot. TranscriptProjection and MarkdownPrewarm retain coarse instrumentation without per-chunk logging overhead. Contracts: TranscriptRenderSegmenterTests, TranscriptRenderLengthCacheTests, TranscriptScrollFollowPolicyTests, AppStoreTests.transcriptBecomesReadyBeforeNashHistoryFinishesLoading.
  • Transcript Markdown cold-open cache — done. Assistant prose is prewarmed off-main. Both layout modes warm only Markdown directly present in their first top-level render set before layout; long virtualized histories limit that set to the newest two-row segment. Collapsed nested streams and the full disk snapshot load/refresh at background priority after reveal. A per-session, schema-versioned binary sidecar under Application Support stores renderer-neutral inline runs (text, presentation intent, link), keyed by the message SHA-256; it never archives SwiftUI views, fonts, widths, or row geometry. Files are atomic, owner-only, excluded from backup, and bounded by a 128 MiB/96 MiB high/low-water budget. TranscriptFirstPaint spans selection through visible, bottom-pinned text and logs the projected row count/layout mode; MarkdownPrewarm plus structural hit/miss logs split memory hits, disk hits, and real Foundation Markdown parses. Contracts: MarkdownRenderDiskCacheTests.
  • Item 5 — done. Literal type payloads over the host surface are bounded (MacVMEngine.typeLongSurfaceText): text ≤ 64 chars keeps the direct low-latency path; longer text routes through the in-guest agent's type op when available (typed in-guest, no host main-actor cost), else is synthesized in 32-character chunks with a 10 ms suspension between them. The chunk loop runs on the (cancellable) MCP handler task, so a stopped action ends at the next chunk boundary; chunks split only between complete strokes, so no modifier is left held. Tests: typeChunksAreBoundedAndLossless, directTypeThresholdKeepsShortTextOnTheDirectPath.
  • Item 6 — done. The launch .task keeps project/session hydration strictly ordered (loadProjectsloadSessions(deferringStartupMaintenance: true)), then runs activity-feed seeding, todo loading, and provider probing concurrently (async let). loadSessions Phase 2 now reads transcripts through a three-slot bounded pool and installs each reconstructed controller as soon as its transcript is ready. A chat selected while cold hydration is still running receives a foreground overflow slot and is explicitly reloaded as soon as its controller exists, so it cannot sit behind unrelated long transcripts. Archived transcripts form a deferred tier: none begins while an active transcript is still hydrating, and they run at background QoS unless the user explicitly opens one. Transcript JSONL decoding is chunked to avoid additional whole-file/line-array copies; DB cache writes follow the observable install. Lock/nvrsion reconciliation runs on its own store-owned task; commands — createSession and all three send seams — gate on awaitStartupLockReconciliation() instead of the reconcile blocking hydration. Container and VM-disk GC reconcile concurrently (reconcileSessionEnvironments), and the rest of the maintenance tail (transfer recovery, Orchestra-orphan + scratch-dir cleanup, keyword backfill, stale-base re-provision) runs exactly once after first paint via runDeferredStartupMaintenanceOnce() — ordered after the lock gate so an orphan delete can never race reconstructLocks. Non-launch callers (revert/undo reloads, nucleicd, tests) keep the original inline tail via the default parameter. Startup signposts (StartupSignposts, subsystem com.nucleic, category startup): LaunchToFirstFrame, Hydration, LockReconcile, DeferredMaintenance — this is item 9's "startup hydration and maintenance" instrumentation slice.
  • Item 7 — done. MacVMEngine.changes() publishes value-deduped, name-sorted [MacVMEntry] snapshots (latest-wins buffering, MergeQueue's subscriber idiom) from every registry mutation — boot, readiness/IP flip, stop, suspend (RAM/disk), resume, guest power-off — re-exposed via MacVMManager.vmChanges() / AppStore.macVMChanges() and primed on subscription. RootView.watchAutoVMMonitor now for-awaits that stream (chat switches, settings flips, and PiP fan picks re-derive via their own triggers against the cached snapshot) with a 30 s reconciliation backstop replacing the 2 s poll; every derived assignment is compared first. The remaining 2 s loops were left deliberately: the VM-monitor/PiP capture loops are frame-cadence work, and the Settings VM tab / Control panel polls aggregate base-build/lock/autoship state beyond the registry (Control already no-op-guards its snapshot). Contract test: registryChangeStreamPrimesEachSubscriberWithCurrentSnapshot.
  • Item 8 — done. BuildRunRunner drains/decodes via an off-main ConsoleBuffer actor and applies bounded batches (~12/s) with a guaranteed final flush.
  • Related (hang-report driven): every NSSavePanel/NSOpenPanel runModal call is now async (begin() / beginSheetModal) — the Jul 18 00:39 hang report caught exportMeshLogs wedging the app in a nested modal run loop. The two DispatchSemaphore waits (LoginShellPATH, OAuthLoopback) were audited: both are reachable only from actor executors, never the main actor.
  • Items 1 and 9 remain (item 9's startup-instrumentation half is already covered by item 6's StartupSignposts; the remaining signposts and the scenario/frame-time measurement pass are still open).

Objective

Keep the macOS desktop UI responsive while several sessions and virtual machines are active. The target is frame-time stability, not merely low aggregate CPU usage: background work must not create long or continuous slices on the main actor.

Completed foundation

The first VM-specific changes landed on dev in merge commit a3cff9613:

  • VM boot now registers a host surface without immediately creating a VZVirtualMachineView, off-screen NSWindow, or WindowServer render workload.
  • Capture, HID, and operator viewing materialize the AppKit surface on demand.
  • An inactive, off-screen surface is released after a five-second grace period while retaining the VM registration and cursor state for later reactivation.
  • All VM framebuffer captures share one global 30-grab-per-second budget. A single monitor retains its current cadence; multiple monitors divide the budget instead of multiplying synchronous cacheDisplay calls.
  • The merge preserved dev's queued mouse-gesture implementation, which avoids blocking in AppKit's mouse-tracking loop.

Remaining work, in order

1. Verify and instrument the VM surface lifecycle

Before expanding the concurrency changes, establish that the new lifecycle is correct and quantify its effect.

  • Build the macOS app and run the full Swift test suite.
  • Add debug counters or signposts for registered surfaces, materialized surfaces, framebuffer grabs, and main-thread capture duration.
  • Add coverage for these transitions:
    • headless VM boot: registered, no AppKit surface;
    • first monitor capture: surface materializes and produces a nonblank frame;
    • monitor disappears: surface releases after the grace period;
    • later capture or HID action: surface rematerializes;
    • operator-assist window: never reaped while on screen;
    • VM stop/restart during activation or capture: no stale view survives;
    • suspended VM: frozen frame remains available without keeping a live surface.
  • Manually compare UI frame pacing with zero, one, and several VMs, both with PiP hidden and visible.

Acceptance criteria:

  • A VM used only by mac_vm_exec has no materialized VZVirtualMachineView after the grace period.
  • Aggregate cacheDisplay frequency never exceeds the global budget.
  • Computer-use input, diagnostic viewing, PiP, suspension, and resume continue to work.
  • No surface-related main-actor slice exceeds one display-frame budget under normal capture.

2. Move per-session event reduction off the main actor

AppStore.observe currently creates one task per session from an @MainActor context. Every event from every active session therefore enters the same serialized ingestUI path, even when the session is in the background.

Implement a SessionUIProjector actor (one per session, or an equivalently isolated keyed service):

  1. Subscribe to the SessionController stream outside AppStore's main-actor executor.
  2. Reduce raw events into a small Sendable UI delta containing only changed summary fields and any open-session transcript tail.
  3. Publish immediately for semantic boundaries:
    • approval requested or resolved;
    • user question;
    • error;
    • run/session status transition;
    • terminal event.
  4. Coalesce ordinary streaming text, thinking, usage, and progress events:
    • open session: target 1012 UI commits per second;
    • background session: target 24 commits per second;
    • always flush the latest pending delta before a terminal event.
  5. Apply the resulting batch in one short main-actor transaction.

Keep canonical transcript writing, event order, sync delivery, conflict arbitration, and persistence semantics unchanged. UI coalescing must never discard canonical events.

Required tests:

  • event order and terminal flush;
  • immediate approval/error delivery during a streaming burst;
  • cancellation and session deletion with a pending batch;
  • several sessions projecting concurrently without cross-session state leakage;
  • open-session switching while old-session work is in flight;
  • equivalence of final summaries before and after coalescing.

3. Stop invalidating the whole sidebar for every event

SessionController advances lastSeq and updatedAt for each canonical event. Reconstructing and writing the full SessionSummary consequently invalidates the observable summaries collection during ordinary token streaming.

  • Separate canonical event freshness from sidebar-visible activity fields.
  • Publish sidebar ordering timestamps only for meaningful user-visible activity, not every text delta.
  • Replace repeated linear firstIndex summary updates with an ID-keyed index or stable per-session row models.
  • Batch summary mutations from step 2 so one scheduler turn produces one observable update.
  • Ensure recents ordering, unread completion, approvals, favorites, archive state, and mesh summaries still update immediately when their visible value changes.

Acceptance criterion: a background session streaming prose must not cause full-sidebar recomputation at token cadence.

4. Incrementalize macOS transcript projection — completed

The open chat currently pulls and reassigns full history, then performs an O(n) projection whenever the transcript version changes. Port the stable-prefix incremental projection already implemented for iOS.

  • Seal completed turns and immutable tool/message groups.
  • Re-project only the live tail for each streaming delta.
  • Keep late lock-note folding correct across the sealed/live boundary.
  • Move pure projection work to a non-main actor and return immutable projected segments to SwiftUI.
  • Preserve a full-fold fallback when stream identity changes, history shrinks, or a sealed identifier is referenced unexpectedly.
  • Add prefix-by-prefix equivalence tests against TranscriptProjection.items for representative Claude, Codex, ACP, subagent, approval, error, and lock streams.

After projection is incremental, keep stable render batches on both layout paths: exact eager layout through 96 rows, then native macOS list virtualization for longer histories. Do not restore direct lazy-stack virtualization; its changing mixed-content estimates regress inertial scrolling and initial bottom anchoring.

5. Bound main-actor VM text injection

Literal VM typing currently synthesizes every character in one main-actor call.

  • Route long text through the guest agent or pasteboard path when available.
  • For synthesized input, send bounded chunks and yield between them.
  • Keep key chords and short text on the low-latency direct path.
  • Add cancellation handling so a stopped computer-use action does not continue typing.

Acceptance criterion: typing a large payload must not block host scrolling or keystrokes for the duration of the payload.

6. Parallelize noncritical startup work

Keep project and session hydration ordered, then split independent work from the first-paint path.

  • After sessions are installed, run activity-feed seeding, todo loading, and provider probing concurrently where their data dependencies allow it.
  • Run container reconciliation and VM-disk reconciliation concurrently.
  • Defer stale-base checks, orphan cleanup, and other maintenance until after the first window paint.
  • Keep commands gated until any required lock/nvrsion reconciliation is complete rather than blocking unrelated UI hydration.
  • Install reconstructed controllers incrementally on the main actor, with a user-selected queued transcript promoted ahead of background hydration.

Add a startup signpost spanning process launch to first interactive frame, plus separate spans for hydration and deferred maintenance.

7. Replace polling with change streams

  • Publish VM registry changes from MacVMManager/MacVMEngine through an AsyncStream and replace the two-second RootView.watchAutoVMMonitor poll.
  • Reuse the same stream for settings and Control-panel VM lists where possible.
  • Keep a low-frequency reconciliation poll only as a defensive backstop.
  • Avoid assigning observable state when the derived value is unchanged.

8. Batch high-volume process output

BuildRunRunner appends output to main-actor observable state one line at a time.

  • Drain and decode output off-main.
  • Publish bounded batches on a short cadence or at process completion.
  • Preserve stdout/stderr identity and the existing maximum-line cap.
  • Apply the same pattern to any other live console that publishes per-line observable mutations.

9. Measure against frame-time acceptance criteria

Add signposts around:

  • AppStore.ingestUI and the replacement projector/commit phases;
  • sidebar summary commits;
  • open-transcript projection and row layout;
  • VM surface activation/release;
  • cacheDisplay, encoding, and decode;
  • VM HID and long-text injection;
  • startup hydration and maintenance.

Exercise at least these scenarios:

  1. One foreground session streaming a long response.
  2. Four background sessions streaming concurrently.
  3. Two or more running VMs with monitors hidden.
  4. Two or more visible VM monitors/PiP cards.
  5. A long transcript streaming while background sessions and VMs are active.
  6. Long VM text injection while scrolling and typing in the host app.

Track p50, p95, and maximum main-thread slice duration, dropped frames, event-to-visible latency for approvals, and aggregate framebuffer grabs. Optimize for p95/max frame time rather than total CPU.

Guardrails

  • AppKit and SwiftUI mutations remain on the main actor; move reduction and pure computation, not UI object access.
  • Canonical event durability and ordering remain lossless even when UI updates are coalesced.
  • Approval, question, error, and terminal state must never wait behind a cosmetic throttle window.
  • Do not introduce an unbounded task, continuation, frame, or event queue.
  • Every cross-actor result must be Sendable or an explicitly reviewed single-owner handoff.
  • Preserve dev's queued mouse-gesture path; synchronous window.sendEvent for down/up sequences can reintroduce the AppKit tracking-loop deadlock.