cmux NEXT: agent pane state files save off the main thread

Merged
TE
7d ago
+276-19

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 waittriggerhow oftenthis PR
AgentUnsentDrafts.save: atomic write of every tab's drafttyping in a new chatevery settled edit (300 ms throttle)moved off main
AgentPaneScrollAnchors.save: atomic write of up to 500 placesscrolling a transcriptevery settled scrollmoved off main
AgentPaneScrollAnchors first readswitching to or opening a chatonce per runstill a read, now ordered behind queued saves
AgentPaneBrowserChoices.list: LaunchServices queries, Info.plist reads, icon renders for 16 appsopening a preview card's "Open in" menueach menu opennext
RemoteRdStreamTransport queue.sync per key and pointer eventremote browser inputeach eventnext
TerminalOutputLane queue.sync on closeclosing a terminal tabeach closenext
icon(forFile:) in drag and Open With menusfile drags, menuseachnext

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 (applicationWillTerminate and the forced-quit path) waits for queued saves under the reviewed allowBlocking(.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

metricbeforeafterdeltaharness or run link
main-thread time per draft save, under agent disk load, p951.0 sone enqueue (no disk on main)-100% of the disk waitscripts/measure/qos-load-bench.sh (#27587), atomic_write_us
main-thread time per draft save, idle, p500.8 msone enqueue-100% of the disk waitsame
saves that block the main thread longer than a frame, under load44% (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, flushAll waits, and unsent drafts and scroll places survive a fresh store.
  • UnsentDraftTests and ChatScrollRestoreDefaultPathTests (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 Autofix with [code]smith
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.

  • AgentUnsentDrafts and AgentPaneScrollAnchors now save through a new AgentPaneStateFile, 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.