cmux NEXT: agent pane state files save off the main thread
Leo (2026-10-29): "well somehow at "100% cpu" usage claude code desktop still seems fast / how does that work? how can cmux next also do that? let's do that"
and: "but i also wonder how much of this is simply putting the desktop at higher priority?" ... "the gui of the desktop app i mean"
This is the app side of the QoS work; the process side is #27587.
Where the main thread stalls under load
The measurements in #27587 (scripts/measure/qos-load-bench.sh, cmux-mac-mini, 10 cores) answer Leo's question:
- CPU is not the problem. A thread at the main thread's QoS runs every 120 Hz frame on time at 100% CPU (0 late of 21,600). cmux already runs at 46, like Claude Desktop's 47-54, so raising the GUI's priority gains nothing.
- Disk on the main thread is the problem. Under agent-like disk load (git, rg, find, dd), a 64 KiB
Data.write(options: .atomic)save took 1.0 s at p95, and 44% of them took longer than a frame, whatever priority the load ran at. Uncached reads and fsyncs stall too (p99 86 ms; fsync p50 33 ms).
An audit of the main thread (every file under Packages/macOS/CmuxNext/Sources) found few such waits: daemon socket I/O already runs on actors, and blocking calls go through checked wrappers. Ranked by how often they hit the interactions Leo named:
| main-thread wait | trigger | how often | this PR |
|---|---|---|---|
AgentUnsentDrafts.save: atomic write of every tab's draft | typing in a new chat | every settled edit (300 ms throttle) | moved off main |
AgentPaneScrollAnchors.save: atomic write of up to 500 places | scrolling a transcript | every settled scroll | moved off main |
AgentPaneScrollAnchors first read | switching to or opening a chat | once per run | still a read, now ordered behind queued saves |
AgentPaneBrowserChoices.list: LaunchServices queries, Info.plist reads, icon renders for 16 apps | opening a preview card's "Open in" menu | each menu open | next |
RemoteRdStreamTransport queue.sync per key and pointer event | remote browser input | each event | next |
TerminalOutputLane queue.sync on close | closing a terminal tab | each close | next |
icon(forFile:) in drag and Open With menus | file drags, menus | each | next |
What this does
AgentPaneStateFile (new, CmuxNextAgentPane) writes one small JSON state file on its own serial utility queue:
- Saves keep call order. A save that a newer one replaced before it ran is skipped.
- JSON encoding runs on the queue too, so the main thread only hands over a value.
read()waits for the saves already queued for that file, so a store made right after a save reads it back. Each path has one writer.- Quit (
applicationWillTerminateand the forced-quit path) waits for queued saves under the reviewedallowBlocking(.terminate)allowance.
The unsent-drafts store and the scroll-place store use it. No new timer or observer: a queue runs only when a save is queued, and quit drains it.
Performance
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| main-thread time per draft save, under agent disk load, p95 | 1.0 s | one enqueue (no disk on main) | -100% of the disk wait | scripts/measure/qos-load-bench.sh (#27587), atomic_write_us |
| main-thread time per draft save, idle, p50 | 0.8 ms | one enqueue | -100% of the disk wait | same |
| saves that block the main thread longer than a frame, under load | 44% (132 / 300) | 0 | -100% | same |
The "after" column holds by construction (the main thread never touches the file). App-level input-to-frame numbers for typing and scrolling under load are not here: they need the GUI bench on a capture mini (the every-action sweep in scripts/measure/blocking-probe.sh under synthetic load), which is the next step.
Testing
AgentPaneStateFileTests: a save runs off the main thread and a read sees it, a burst ends with the last value, nil removes the file,flushAllwaits, and unsent drafts and scroll places survive a fresh store.UnsentDraftTestsandChatScrollRestoreDefaultPathTests(existing) still read back right after a write.scripts/cmux-next/check-concurrency.sh: ok.
No UI changes, so there are no screenshots.
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Moves the agent panes' state file saves off the main thread so typing and scrolling no longer stall on disk I/O under agent load.
AgentUnsentDraftsandAgentPaneScrollAnchorsnow save through a newAgentPaneStateFile, which encodes and atomically writes on one serial utility queue per file, keeps call order, and skips saves superseded before they run. A throwing encode leaves the file as the last save that ran left it; nil removes it.- Reads wait for the file's queued saves; only a read from inside that file's own save reads the disk directly, and each file queue has its own on-queue key so a save can't read another file's saves out of order. App quit flushes pending saves under the terminate allowance, bounded by a shared deadline that reports whether all drained.
- Under agent disk load the old main-thread save took 1.0 s at p95 and 44% of saves ran over a frame; the main thread now only enqueues the save.