Repository navigation
cmux-tui: the daemon no longer deadlocks settling a late registry receipt; server-stats reports both locks by site - #28147
Conversation
80 fake terminal agents run the real hook helper (one durable journal append per turn event) and a CLI call in a loop, a churn client creates and closes terminals, and an app client sends what the cmux app sends (put-frontend-projection window saves, list-workspaces, session.snapshot, identify). It reports the app requests' latency, the requests over the app's 2 s timeout and both daemon locks by site, the shape of the 2026-10-29 report (#27698). Run: cmux-ci run --class exclusive --measurement --script scripts/measure/perf-10x-daemon.sh --ref SHA --arg=scenarios=agents Measurement only; no CI step, gate or budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olders by site server-stats include=["locks"] reports the workspace registry lock and, new, the mux state lock: wait and hold histograms, the current holder, the last stall, and the top twenty acquiring sites by total hold and by p99 hold, each with its hold and wait p99 and maximum. Sites come from #[track_caller] (lock_state_pinned now carries it too, so its callers are named instead of the wrapper). Per site, two power-of-two histograms (256 bytes each) are updated under the site table lock that the release already took, while the lock is still held: no allocation after a site's first acquisition and no new contention. The state lock's existing rank and holds bookkeeping is unchanged. The section is optional (absent unless named), so older SDK decoders keep working; schema, bindings, api-compat surface and commands.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pers in lock stats #[track_caller] on the Mux wrappers that take the workspace registry or the mux state lock (with_state, surface, resolve_resource_path, commit_state, commit_resource_mutation_plan, with_resource_projection, the resource effect helpers, tree_decorations and others), so server-stats locks names the request path that holds the lock instead of the wrapper's own line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…again (red) lock_state_pinned settles the late answers of indeterminate registry commits (cx-g1fa.3) while it holds the registry and state. The settle then ran publish_resource_event, whose pending-directory step locks state again: a lock-rank panic in debug builds and a self-deadlock of the whole daemon in release builds (std Mutex), on a path a loaded Mac reaches when a journal commit outlives its 30 s receipt wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…urnal event The settle runs under the registry and state; publish_resource_event also publishes pending terminal directories, which locks state again (and then the registry). Publish the journal event only: subscribers still read the new head, and a directory still pending is published by the terminal's next output or the next committed graph change, as before the settle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lippy redundant_clone) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Correctness review (independent subagent, given the diff and the PR body): SHIP
Owner PL-daemon (cmux-b6): this changes the daemon's lock path, please take a look. Review (claude-subagent): SHIP at 100b8e3 |
|
Found 6 test failures on Blacksmith runners: Failures
|
…leases the site table Every release of the registry and state locks takes the site table mutex; detail() now copies the raw records out and formats the file:line labels after the unlock (review of #28147). Schema text: the holder site names either lock, and sites counts the locations that released the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review (claude-subagent): SHIP at 100b8e3 The same verdict as the review comment above, with the receipt line first. |
|
Review (claude-subagent-fresh): SHIP at 100b8e3 Fresh, independent correctness review. I read the code at the exact head through Deadlock: real, and fully fixed
|
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
Shipped in:
|
Generated SDK bindings conflicted (manifests, Java Protocol IR hash): took feat's side, then regenerated every binding from the merged schema with cmux-tui/bindings/codegen/generate.py --write (--check passes).
|
Review (Claude Opus 5.5 reviewer): SHIP at c842b42 I read the code at the exact head in a detached worktree. I did not compile or run tests (fleet only). I ran only the pure-Python codegen check. Deadlock fix (correct)
Lock statistics (correct)
Merge c842b42 (clean)
Evidence
Non-blocking
|
# Conflicts: # cmux-tui/bindings/cpp/.cmux-sdk-manifest.json # cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json # cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java # cmux-tui/bindings/java/src/com/cmux/raw/ServerStatsResult.java # cmux-tui/crates/cmux-tui-core/src/diagnostics.rs # cmux-tui/crates/cmux-tui-core/src/server/server_stats.rs # cmux-tui/crates/cmux-tui-core/src/server/server_stats_tests.rs # cmux-tui/spec/commands.md # cmux-tui/spec/sdk-schema.json
|
Review (claude-subagent-merge-review): SHIP at 51e2350 Independent review of the merge head 51e2350 (parents c842b42, the PR head, and 7008ce7, the feat-cmux-next tip). I did not write this code. I read the code only. I did not build or test on the review machine. Merge resolution (the
|
|
Checks at the head 51e2350 (pa-b3),
Still pending: cmux-next swift test, fast tier, risk evidence, merge freeze, pr-perf-section-check, pr-capture-check, changes, plan. This PR's diff against its merge base changes only cmux-tui-core, cmux-tui/spec, cmux-tui/bindings and one bench script. Its focused tests (290d1a83, 5a591e10, c37071d4: 1 passed each) and its compile (3f6e8bcf) pass at this head. |
…olders Conflicts were feat's squash of #28147 (pa-b3) plus its later hook_queue section against this branch's copy of the pa-b3 commits. Rust: feat's side (hook_queue, try_lock outside tests). Generated bindings and spec: feat's files (this PR changes no protocol). bench-daemon-latency.py: this PR's --agent-cli and agents-hooks scenario on feat's agents scenario.
…aits Conflicts were feat's squash of #28147 (pa-b3) plus its later hook_queue section against this branch's copy of the pa-b3 commits. Kept this PR's changes: SiteStats copied out before the site labels are formatted, the new writer_lock_order tests, and the state-lock comment. Took feat's hook_queue (diagnostics, server_stats, tests), try_lock outside tests, and feat's generated bindings and spec (this PR changes no protocol).
…progress report reads only its own tabs (#28534) * bench(cmux-tui): agents load scenario for the daemon latency bench 80 fake terminal agents run the real hook helper (one durable journal append per turn event) and a CLI call in a loop, a churn client creates and closes terminals, and an app client sends what the cmux app sends (put-frontend-projection window saves, list-workspaces, session.snapshot, identify). It reports the app requests' latency, the requests over the app's 2 s timeout and both daemon locks by site, the shape of the 2026-10-29 report (#27698). Run: cmux-ci run --class exclusive --measurement --script scripts/measure/perf-10x-daemon.sh --ref SHA --arg=scenarios=agents Measurement only; no CI step, gate or budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cmux-tui): server-stats locks section: registry and state lock holders by site server-stats include=["locks"] reports the workspace registry lock and, new, the mux state lock: wait and hold histograms, the current holder, the last stall, and the top twenty acquiring sites by total hold and by p99 hold, each with its hold and wait p99 and maximum. Sites come from #[track_caller] (lock_state_pinned now carries it too, so its callers are named instead of the wrapper). Per site, two power-of-two histograms (256 bytes each) are updated under the site table lock that the release already took, while the lock is still held: no allocation after a site's first acquisition and no new contention. The state lock's existing rank and holds bookkeeping is unchanged. The section is optional (absent unless named), so older SDK decoders keep working; schema, bindings, api-compat surface and commands.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cmux-tui): name the callers of the registry and state lock wrappers in lock stats #[track_caller] on the Mux wrappers that take the workspace registry or the mux state lock (with_state, surface, resolve_resource_path, commit_state, commit_resource_mutation_plan, with_resource_projection, the resource effect helpers, tree_decorations and others), so server-stats locks names the request path that holds the lock instead of the wrapper's own line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): settling a late registry receipt must not lock state again (red) lock_state_pinned settles the late answers of indeterminate registry commits (cx-g1fa.3) while it holds the registry and state. The settle then ran publish_resource_event, whose pending-directory step locks state again: a lock-rank panic in debug builds and a self-deadlock of the whole daemon in release builds (std Mutex), on a path a loaded Mac reaches when a journal commit outlives its 30 s receipt wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): settling a late registry receipt publishes only the journal event The settle runs under the registry and state; publish_resource_event also publishes pending terminal directories, which locks state again (and then the registry). Publish the journal event only: subscribers still read the new head, and a directory still pending is published by the terminal's next output or the next committed graph change, as before the settle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): drop unnecessary path qualifications in the lock tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): move the mux into the settle test's pinning thread (clippy redundant_clone) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): StateGuard holds its MutexGuard directly; no expect (crash ratchet) The state guard kept its MutexGuard in an Option only to record the release before the unlock, and its Deref used two expect() calls: the crash ratchet counted them (cmux-tui-core expect 190 -> 192, #28147's red 'cmux-next crash ratchet'). Fields drop after Drop::drop, so the release is recorded in the body while the lock is still held, with no Option. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): the lock site histograms index and count without a panic path (panic ratchet) diagnostics.rs: the per-site octave histogram indexed its bucket array and added with +=, which the Rust panic ratchet counts (cmux-tui-core indexing_slicing 854 > baseline 853; arithmetic_side_effects rose too; review of #28147 at c88bc3a). The bucket is now taken with get_mut, and the counts, the running sum, the p99 bound and the acquisitions use saturating or checked arithmetic. The values are unchanged: bucket 0 holds 0, bucket i holds 2^(i-1)..2^i - 1, the last bucket everything above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * bench(cmux-tui): the agents scenario can run the hooks only (--agent-cli 0) No shipped agent hook, plugin or status line calls the cmux CLI on each turn; the hooks send only session.journal.append. agent_cli=0 runs that load (lead decision 2026-10-30: the hook-only load is Austin's day). A diagnostic fleet bench, not a CI step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): the terminal revision read takes no registry lock, and a progress report reads only its own tabs (red) #27698 lock work, design step 1. On Austin's day (9 runs, pa-d harness 856cf72) the registry lock is held about 78 % of the time. Two of its largest holders on the hook-only load: - frontend_projection.rs:9, the terminal revision that identify and every list-workspaces reply read: 151,415 acquisitions, 7.0 % of the run; - terminal_progress.rs:18, a progress report that read the whole session topology to find one terminal's tabs: 7.1 % of the run, max 2 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): the terminal revision is read without the registry lock; a progress report reads only its own tabs #27698 lock work, design step 1 (1d): - The registry gets a read-only connection to its own file (RegistryConnection::read_committed). In WAL mode it reads the last committed state and never waits for a writer; its mutex is a leaf held only for the read. terminal_registry_revision, which identify and every list-workspaces reply call, reads there, with no registry lock. An in-memory registry (no file) and a failed read keep the locked read. - publish_terminal_progress reads this terminal's own tab rows (resource_tabs_of_content) instead of the whole session topology, under the registry lock it still needs for its commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): a committed read during an open write transaction returns the last committed value read_committed (the registry's read-only WAL connection) returns the state committed when its read starts: never a partial or uncommitted write, and it does not wait for the open transaction. After the commit it returns the new value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * bench(cmux-tui): run the hook-only load as scenario agents-hooks; keep the reviewed fleet script The fleet refuses an allowlisted script that differs from its reviewed version on main or feat-cmux-next (scripts/measure/perf-10x-daemon.sh, steps 142d441b and deb15036). perf-10x-daemon.sh is back to the reviewed blob 85fb5db; the hook-only load is a scenario of the Python bench (--arg=scenarios=agents-hooks), which that script passes through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): the progress report test names the tables only a whole-topology load reads The test asserted that a progress report reads no resource_screens, resource_panes or resource_workspaces row. The commit of any resource patch (even an empty one) looks up such rows, so the test failed on the fixed head too (step c9a332d2). The whole-topology load is marked by the per-screen side tables that only it reads (resource_screen_rows, resource_column_docks, resource_lone_docks, resource_parked_splits). The red step 1e49666e at 044b745 read all four; the fixed head reads none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… or the registry connection (#28159) * bench(cmux-tui): agents load scenario for the daemon latency bench 80 fake terminal agents run the real hook helper (one durable journal append per turn event) and a CLI call in a loop, a churn client creates and closes terminals, and an app client sends what the cmux app sends (put-frontend-projection window saves, list-workspaces, session.snapshot, identify). It reports the app requests' latency, the requests over the app's 2 s timeout and both daemon locks by site, the shape of the 2026-10-29 report (#27698). Run: cmux-ci run --class exclusive --measurement --script scripts/measure/perf-10x-daemon.sh --ref SHA --arg=scenarios=agents Measurement only; no CI step, gate or budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cmux-tui): server-stats locks section: registry and state lock holders by site server-stats include=["locks"] reports the workspace registry lock and, new, the mux state lock: wait and hold histograms, the current holder, the last stall, and the top twenty acquiring sites by total hold and by p99 hold, each with its hold and wait p99 and maximum. Sites come from #[track_caller] (lock_state_pinned now carries it too, so its callers are named instead of the wrapper). Per site, two power-of-two histograms (256 bytes each) are updated under the site table lock that the release already took, while the lock is still held: no allocation after a site's first acquisition and no new contention. The state lock's existing rank and holds bookkeeping is unchanged. The section is optional (absent unless named), so older SDK decoders keep working; schema, bindings, api-compat surface and commands.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cmux-tui): name the callers of the registry and state lock wrappers in lock stats #[track_caller] on the Mux wrappers that take the workspace registry or the mux state lock (with_state, surface, resolve_resource_path, commit_state, commit_resource_mutation_plan, with_resource_projection, the resource effect helpers, tree_decorations and others), so server-stats locks names the request path that holds the lock instead of the wrapper's own line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): settling a late registry receipt must not lock state again (red) lock_state_pinned settles the late answers of indeterminate registry commits (cx-g1fa.3) while it holds the registry and state. The settle then ran publish_resource_event, whose pending-directory step locks state again: a lock-rank panic in debug builds and a self-deadlock of the whole daemon in release builds (std Mutex), on a path a loaded Mac reaches when a journal commit outlives its 30 s receipt wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): settling a late registry receipt publishes only the journal event The settle runs under the registry and state; publish_resource_event also publishes pending terminal directories, which locks state again (and then the registry). Publish the journal event only: subscribers still read the new head, and a directory still pending is published by the terminal's next output or the next committed graph change, as before the settle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): drop unnecessary path qualifications in the lock tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): no request holds state across a writer receipt, or the connection across an unsettled wait (red) Two holders that wait while they hold a lock (#27698 audit, lock waits p90 5 s / p99 17 s on a loaded Mac): - rename-workspace held the mux state lock across its journal writer receipt wait (up to the 30 s durable wait); - a request that found an earlier indeterminate commit waited for its answer (2 s) with the registry connection pinned, though the writer needs that connection to commit and answer: the wait could only run out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): no request waits for a writer answer while it holds state or the connection - rename-workspace releases the mux state lock before its journal writer receipt wait and takes it again to apply the committed name (found by workspace key). The registry stays held, so no topology writer interleaves; state readers see the old name until the commit is durable, instead of queueing behind the disk for up to the 30 s durable wait. - lock_state_pinned waits for the answers of earlier indeterminate commits before it pins the registry connection, and drain_unsettled never waits while this thread holds the connection: the writer needs the connection to commit and answer, so that wait could only run out (2 s) and fail the request. A commit still unanswered after the unpinned wait is refused at once, as it was after the pinned wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(cmux-tui): PinnedState has no unpin; no receipt wait under state With rename no longer holding state across its receipt, nothing unpins a held state any more: PinnedState always holds its connection pin, and commit_registry_intent_with loses its release_pin hook. A debug assert refuses a journal writer receipt wait while the thread holds the mux state lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): a rename's reply comes after its name is in state; readers see the old name only until then Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): move the mux into the settle test's pinning thread (clippy redundant_clone) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): server-stats locks formats its site labels after it releases the site table Every release of the registry and state locks takes the site table mutex; detail() now copies the raw records out and formats the file:line labels after the unlock (review of #28147). Schema text: the holder site names either lock, and sites counts the locations that released the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): StateGuard holds its MutexGuard directly; no expect (crash ratchet) The state guard kept its MutexGuard in an Option only to record the release before the unlock, and its Deref used two expect() calls: the crash ratchet counted them (cmux-tui-core expect 190 -> 192, #28147's red 'cmux-next crash ratchet'). Fields drop after Drop::drop, so the release is recorded in the body while the lock is still held, with no Option. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): the lock site histograms index and count without a panic path (panic ratchet) diagnostics.rs: the per-site octave histogram indexed its bucket array and added with +=, which the Rust panic ratchet counts (cmux-tui-core indexing_slicing 854 > baseline 853; arithmetic_side_effects rose too; review of #28147 at c88bc3a). The bucket is now taken with get_mut, and the counts, the running sum, the p99 bound and the acquisitions use saturating or checked arithmetic. The values are unchanged: bucket 0 holds 0, bucket i holds 2^(i-1)..2^i - 1, the last bucket everything above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…-tui, tool argv, held-share test (#28505) * scripts(cmux-next): Austin's day, the P-AUSTIN load scenario on an exclusive fleet mini 80 terminal agents through the real claude shim, wrapper and hooks (a fake Claude that runs the hook settings it is given on every tool call and stop, and calls the cmux CLI), a partial clone with 19,110 promisor packs, 25-chat acpmux waves, a bounded memory hog up to swap, and workspace closes through the app's closeWorkspace action. Measures app daemon requests (probe and log time-outs), refused connections, lock waits, close time, acpmux restarts, memory, load and swap, and the debug.hangs stalls over 100 ms by top cmux symbol. One JSON plus a text summary; --base/--head alternate on one host. A fleet bench each agent runs itself; not a CI job or gate. selftest.py (run by hand): 4 tests OK on cmux-orchestrator-1. * austins-day: resolve a job id without an empty --ref argument First run on the feat-cmux-next tip (146a973, build ab32ee84) on cmux20: 80/80 agents hooked, 13,693 hook runs; daemon refused 378 connections (peak 64/64); registry lock wait p99 393 ms (max 5.2 s); person CLI p99 4.65 s; 7 of 10 workspace closes timed out at 2 s (p95 8.7 s, one never closed); 71 cmux main-thread stalls over 100 ms. Not seen: acpmux restarts, app no-reply log lines (the log query found none; the app probe is pending). * austins-day: agents draw to their terminals and use CPU; swap to 14 GB; stalls by own cmux symbol Run 1 had load 35-54 (Austin 150-252) and swap 9 GB (Austin 17 of 18): the fake agents were silent between tool calls. Each now draws as Claude Code's TUI does while it thinks (a spinner at 10 Hz, ~300 chars/s of streamed text) and keeps 15 % of a core busy, and the hog targets 14 GB of swap. A stall is named by its first frame in cmux's own code (a CmuxNext module), not by a stdlib generic specialized into the binary. The app log query also counts the daemon category's 'failed' lines. * austins-day: validity rules, owner, 5 runs per arm with bootstrap verdicts, long-arm metrics (cmux-ad) - An A/B gives no verdict when any base run shows none of Austin's failures ('invalid: base did not reproduce') or when the head changes this folder ('invalid: head edits the harness'). - The README names the owner (the P-AUSTIN harness owner, agent pa-d): only the owner edits the folder and runs each P-AUSTIN PR's A/B. - 5 alternating runs per arm by default. Per metric: a paired percentile bootstrap 95 % CI of mean(head - base) over the rounds (10,000 resamples, fixed seed); 'better'/'worse' only outside the interval, else 'no difference'. - Long-arm metrics: open fds per process (lsof) and a per-hour growth slope of RSS, footprint and fds. The 60 min arm itself is blocked by the 10 min capture-host lease (README). selftest.py: 7 tests OK (cmux-orchestrator-1). * austins-day: stalls split into CODE and WAIT (P-MAIN rule G, lead 2026-10-30) A stall over 100 ms is CODE when cpu_ms is at least half of duration_ms (or the app's own debug.hangs cause, from fix/stall-hang-wait-split on), else a WAIT. Only CODE stalls with a cmux frame are reds by top cmux symbol and count in austin_failure; WAITs are counted apart (count, total ms, largest CPU share, page-ins) and reported next to swap and memory level, owner B8. Run 1 re-read: 92 stalls over 100 ms, 0 CODE, all WAITs (max CPU share 27 %, 43.9 s wall, 0.89 s CPU), so its 3 named sites are not reds. selftest.py: 8 tests OK (cmux-orchestrator-1). * austins-day: hook CPU per hook run, per-metric validity, CLI and close p99 rows For the P-AUSTIN A/Bs (#28041 B2: hook CPU; #28159 B3: lock waits, app requests, workspace close): - fake_claude logs each hook's CPU (wait4 user + system of the hook command and the children it waited for). A hook past its timeout is killed only while it is not yet reaped (WNOWAIT, then a lock), never a reused pid. - Each metric needs its own failure on every base run (METRIC_FAILURE: the daemon's 100 ms lock-stall line, the B3 50 ms app-request target, the 2 s action deadline, refused connections for CLI and hook rows); else that metric says 'invalid: base did not reproduce'. No pairs: 'not measured'. - New rows: agent CLI p99, hook CPU total and p99, workspace close p99. selftest.py: 9 tests OK (cmux-orchestrator-1). * austins-day: each run records the harness commit; refuse uncommitted harness edits * austins-day: hook waits work on the minis' Python 3.9; harness faults void a run; a failed arm is retried once The #28159 A/B (10:15Z-12:05Z) logged no hook runs: the minis run Python 3.9.6, which has no os.waitid on macOS, so each hook thread died right after it started its hook. The hooks still ran but nobody waited for them (Claude Code's sync hooks became fire-and-forget) and their time and CPU were lost. - The hook wait is a kqueue process-exit event on macOS (a pidfd on Linux); the child stays unreaped until wait4, so a time-out kill never reaches a reused pid. - A crash in a hook thread is logged as the hook's result. - A run whose hooked agents logged no hooks, or whose hooks hit a harness error, makes the A/B 'invalid: harness faults'. - A run that got no lease or no app is retried once (head round 5 got no capture host before the acquire time-out). selftest.py: 9 tests OK. * austins-day: refused as a rate and a share; base must be the PR's parent; who fills the pool From the #28159 review (pa-b3, issuecomment-6523481295), both points real: - 'refused' was a cumulative count read at each run's last answered server-stats, which came 28-47 s earlier on some base runs, so the arms were read at different times. Arms now compare refused per second over each run's own server-stats window and refused share of connects. - The base was the merge base with feat (9fa133b) while #28159 sits on the open #28147 (100b8e3), so the A/B measured both PRs. The README now says the base is the PR's parent; the summary lists base..head and refuses a base that is not the head's ancestor. - New: pool_holders, sampled with server-stats: the daemon's list-clients by kind/name, live hook-helper and CLI processes, and the refusals each client kind saw (lead: whose connections fill the 64). selftest.py: 11 tests OK. * austins-day: A/A self-test (cmux-ad) austins-day.sh --aa JOB runs one build in both arms (5 alternating rounds). summarize.py --aa passes a metric whose 95 % interval contains 0 and fails the rest; the owner records failures in aa-failures.json, and every later summary marks those metrics 'invalid (fails A/A)'. An A/A with two builds is refused. selftest.py: 13 tests OK. * austins-day: paired t-interval instead of the bootstrap; ABBA run order; a position estimate per metric (lead decision) The A/A on build 84c705ad (3 rounds) failed 10 of 28 metrics on identical arms with the percentile bootstrap, which is far too narrow over 5 or fewer pairs; a paired t-interval (95 %, t with n - 1 df) failed 2. The runner now alternates which arm runs first (ABBA; arm.json records the position), and each metric gets a position estimate (second run - first run) so a warm-up or drift effect shows apart from the arm difference. selftest.py: 13 tests OK. * austins-day: A/A of 2026-10-30 recorded (passes with the t-interval in ABBA order); the runner runs a private copy A/A on build 84c705ad (3ecd802), cmux21, 5 rounds, paired t-interval: - as run (base first in every round): 27 of 28 metrics pass; acpmux RSS growth MB/h fails, a run-position effect (the second run grows less); - labels in ABBA order: 28 of 28 pass. aa-failures.json records no failed metric and the position effect; app request p99 is not tested (no probe). The A/A run died at its very end because austins-day.sh was edited while it ran (bash reads a script as it runs); the runner now execs a private copy. * austins-day: usage reads the harness file, not the unlinked private copy * austins-day: acpmux chat-open failures per run (pa-b10's measure) Every A/A run and every #28159 rerun run failed most acpmux chat opens: session/new answered 'agent host start did not finish within 10s' (and some 'agent_start did not finish within 90s') from the second 25-chat wave on. New rows, carried in every A/B table: acpmux chat opens, open failures, failure share, host-start 10 s time-outs, agent_start 90 s time-outs; open failures count as an Austin failure and need a failing base per metric. selftest.py: 13 tests OK. * austins-day: fixes from the review of aa7c09e (CHANGES) Should-fix: 1. Workspace close: the app and the daemon are polled in their own threads from the moment the action is sent, so a slow 'cmux workspace list' or the action's own reply no longer delays the app's reading. 2. The whole harness (scripts, summarize.py, aa-failures.json, build_names.py, the acpmux test agent) runs from a private copy made at the start; an edit to the checkout during an A/B changes nothing in it. 3. A run whose agents did not all start, or did not all get hooks, is a harness fault: the A/B is invalid (less load would look 'better'). 4. packs.py compares only its parameters with READY ('made' is a result), so the 19,110-pack fixture is built once per host, not in every lease. 5. Every step of run_arm that must work checks itself (set -e does not act inside && and ||): staging, copy, fixture build, launch. Notes: the app probe without a start read is 'not measured'; a harness-fault message names each broken run; a killed script releases its lease (EXIT trap) and never signals a reaped acquire pid; the fake Read tool reads the fixture's dir*/ files; README says which series cover only the load phase and what 5-round ABBA leaves of a position effect; hog docstring corrected. selftest.py: 14 tests OK. * austins-day: fixes from the re-review of 2b7fd10 (CHANGES) Should-fix: 1. A workspace close starts only when it can end before the load deadline, and its watchers stop at their own 30 s bound, not at the run's stop flag: the end of a run no longer cuts a close and counts it 'not done'. 2. Each Chief-wave chat is one attempt (the driver records chats per wave); a chat whose connect is refused, closed or timed out counts as a failed open, so an acpmux restart or hang is not hidden. New row: connection failures. 3. 'acpmux chat opens' is no longer a lower-is-better verdict row (a faster head runs more waves). Notes: missing agent terminals (fewer than --agents) are a harness fault; the uncommitted-edit check also covers build_names.py and the acpmux test agent; the private copy is removed on every exit, usage errors too; on INT/TERM/HUP the runner stops a waiting lease request of its own (still a live job, never a reaped pid), removes the remote run folder and releases the lease; the close watchers poll every 100 ms; the RETURN-trap comment is corrected. selftest.py: 14 tests OK. * austins-day: a refused acpmux connect is a failed chat open (re-review of a84ddb0) Should-fix: one_chat kept answers[1:], which dropped the failed connection (the only entry when the connect is refused or initialize never answers), so the chat was stored empty and counted nothing. It now drops the initialize answer by its method and never stores an empty chat; summarize counts an empty chat from older runs as a failed open. New test ChatRecord runs the driver's own one_chat against a socket nobody listens on (red on the old line: IndexError, the chat was empty; green now). Notes: the close bound is the real worst case, 125 s; the private copy is removed only under $TMPDIR/austins-day.* again; the fallback for runs without a chat record counts session/new only (an undercount, in the README); the raw open-failure count is no longer a verdict row (the share is). selftest.py: 15 tests OK. * austins-day: summarize reads runs from drivers before the chat record (review of b7574be) Drivers before 152ee4f (the A/A's 02ebc4c among them) wrote "chats": <wave size>; chat_firsts looped over that int and raised TypeError, so those runs never reached the session/new fallback. It now reads chats only when it is a list. New test OldRuns (a wave with "chats": 25). The A/A runs re-read with this summarize: 31 of 31 tested metrics pass in ABBA order. selftest.py: 16 tests OK. * austins-day: the answers fallback reads chats only when it is a list (delta review of 76f61c1) An old run's wave with "answers": [] and "chats": 25 (every chat failed to connect) raised TypeError in the answers line. Same isinstance check as chat_firsts; OldRuns now has such a wave. selftest.py: 16 tests OK. * austins-day: label the agent CLI rate as assumed (lead decision 2026-10-30) The fake agents' 'cmux workspace list' every 3 tool calls is assumed, not from Austin's data; the #28041 A/B's process samples show it fills the 64-slot pool with CLI calls (median 62-63 live cmux CLI processes, 0 hook helpers), so hook-path fixes cannot show in refused connections. The README and every summary (text and JSON) now say so. Kept for comparability until Austin's real client mix is measured. selftest.py: 16 tests OK. * austins-day: registry-lock held share; pool holders from 'cmux client list' - summarize: registry_lock_held_share, the change of hold count x mean hold between the run's first and last server-stats, over that window (lead 2026-10-30, for the B3 #28147 A/B; 81-82 % in both arms of the B1 A/B re-read). A new row; no existing computation changes. - driver: the pool-holder sample reads 'cmux client list' (client_kind, name, transport, connected_seconds); 'raw command list-clients' was refused (usage.invalid) in every run so far. Descriptive, outside every verdict; it runs in the sampler as before. selftest.py: 16 tests OK. * austins-day: the client list is read as the CLI prints it; held share is descriptive (review of f8a8afb) - 'cmux --json client list' prints the daemon's bare array; the driver's value() turned it into {}, so the pool-holder sample still read nothing. client_list_entries() takes a bare array or a {value|clients|items} list. New selftest ClientList runs it on a bare-array sample. - The registry lock held share is not A/A-tested and has no base-failure rule: it is no longer a verdict row; each run's text line shows it as descriptive, with its integer-mean bias (~1-2 points low at most). No existing metric's computation changes. selftest.py: 17 tests OK. * austins-day: held-share bias stated per hold; selftest for lock_held_share (review follow-ups of 856cf72) The daemon's mean hold is a whole number of microseconds, so the held share is low by up to about 1 µs per hold in the window (an exact value needs the daemon to report its hold sum). New test LockHeldShare: the change of count x mean over the window, no window, no stats, and a counter reset clamped at 0. selftest.py: 18 tests OK. * austins-day: pool holders through cmux-tui; each tool call records its command - 'cmux client list' is refused (usage.invalid) by design, not by a stale bundled CLI: 'client' and 'raw' are in CMUX_TUI_ONLY_SCOPES (cli.rs), and the app bundles cmux-tui beside cmux. The holder sample now runs the bundled cmux-tui. This is why both 'raw command list-clients' and 'client list' read nothing in every run so far. - fake_claude logs each tool call's argv: the #28147 A/B's first run on cmux17 had 157 git calls hit the 120 s time-out, and the log did not say which command. (A cold fixture was ruled out: every first run of the 63 earlier runs also built the fixture and had 0 tool time-outs.) Neither changes a verdict-row input: the holder sample is descriptive and the argv is one more field in a log line. selftest.py: 18 tests OK. * austins-day: main arm without agent CLI calls; lock detail and client list after the load (lead 2026-10-30) - Main arm: --cli-every 0 is the default. No shipped hook, plugin or status line calls 'cmux workspace list' per turn (hooks send only session.journal.append), so the old one-per-3-tool-calls had no evidence and drove 43 % of the registry lock busy share. --cli-every 3 stays as the labeled 'assumed' sensitivity arm; the summary names the arm and refuses runs with mixed rates. This changes a verdict-row input: the new main arm needs its own A/A. - server-stats include ["locks"] (cmux-tui raw command), read once right after the load: lock_top_sites, the top 20 sites of the registry lock and mux.state by total and by p99 hold. Descriptive. - The daemon's client list is read only once, after the load; during the load the holder sample counts processes only, as in the earlier A/As (review note of cdd562d: the working probe added a connection and a registry-lock acquisition every 15 s to the measured window). selftest.py: 20 tests OK. * austins-day: notes of the review of 3bf19c2; the A/A record is per scenario - The samplers stop before the post-load reads (locks, client list), so the end read stays the last server-stats and no sample falls in those calls. - The post-load client list is summarized (pool_holders_after_load). - aa-failures.json is keyed by scenario (agent CLI rate): '3' holds the earlier A/As; a summary whose scenario has no A/A marks every verdict 'not A/A-checked' (the main arm until its A/A is recorded). Runs that recorded no rate are counted as the old default, 3. - The scenario note names the real rate; the lock-detail line says its numbers count since the daemon started. selftest.py: 21 tests OK. * austins-day: a revert pair is a valid base; requested paired comparisons; named lock sites (lead, B3 1d) - B3 1d's 'before' is the fix's own tree plus one commit reverting the fix (b472663 = fee4db7 + revert). The base is then not an ancestor of the head, which the validity rule refused. A base whose parent is the head and whose subject starts with 'Revert ' is now accepted; measured_commits names the reverted commit. - 'requested paired comparisons': the same paired t-interval and n for values that are not verdict rows (registry lock held share, lock wait p99, app request p99, and the hold p99 and total hold of the registry lock at mux/frontend_projection.rs:9 and mux/terminal_progress.rs:18, from the post-load lock detail). No verdict, no A/A check. No verdict-row input changes. selftest.py: 22 tests OK. * austins-day: a strict revert pair; named lock sites by function (review of 2ea5ef9) Should-fix 1: is_revert_pair now needs exactly one parent (the head), a 'This reverts commit X' line with X an ancestor of the head, and the base-to-head diff equal to X's own diff; the summary prints every measured commit. The test adds an empty 'Revert' commit and a merge (both refused). Should-fix 2: B3 1d moves the terminal revision read's lock from frontend_projection.rs:9 to its fallback at :20, so a site named by line never paired. Sites are now named by function (NAMED_LOCK_SITES lists the lines per build); several lines add up (total) or take the max (p99); a site in neither top-20 list counts as 0 when the lock's sites count shows all were listed, else unknown; a requested comparison shows how many rounds it dropped for an unknown value. New test NamedSites. selftest.py: 23 tests OK. * austins-day: an upper bound for a named lock site absent from a full list; bytes-safe revert diff (review of 7b1d2cd) The registry lock has far more than 20 sites, so a named site that is rarely used (B3 1d's fallback at frontend_projection.rs:20) is in neither top-20 list: its value is unknown and its round is dropped. Each run now also records an upper bound for it, the smallest listed value (hold_total_us_at_most, hold_p99_us_at_most), shown on the run's line and never mixed into a paired difference. The revert-pair diffs are read as bytes. README notes NAMED_LOCK_SITES fits the builds it names. selftest.py: 23 tests OK. * austins-day: a lock-site bound is rounded up on the text line; no site lines without lock detail (notes of the review of 748dc58) A 40,000 us bound printed as 'at most 0.0 s', a smaller bound than the data gives; it is now rounded up (0.1 s). Runs from builds without the lock detail print no named-site lines. Text only: no recorded value changes. selftest.py: 24 tests OK. * austins-day: selftest: move an assertion back into its own test 61b4d29 was committed with selftest.py red (NameError): the new test was inserted above the last line of NamedSites' first test, so that line ran in the new test. Code unchanged. selftest.py: 24 tests OK. * austins-day: record the main-arm A/A; the holder sample names what its processes run - aa-failures.json scenarios['0']: the main-arm A/A of 2026-10-30 (build 84c705ad, cmux24, harness 8d4f908, 5 ABBA rounds): 30 of 30 tested metrics pass, none fails; agent CLI p99 and app request p99 not tested; workspace close not tested (base did not fail). The base round 1 note (one ssh date by the owner at the start of its load) is recorded. - With no agent CLI calls, the A/A still had a median of 48 live processes of the bundle's 'cmux' and the pool still full (refused 6.2/s); the role alone did not say what they run. The holder sample now records, from the same ps read, each role's command shapes (binary, then the command words without --socket, --json and ids), by count; summarize keeps the median per shape (process_kinds_median). Descriptive; no verdict input changes. selftest.py: 25 tests OK. * austins-day: hook-helper shapes keep their event; A/A record wording (notes of the review of 59ea3fd) - The bundle's hook helper path has spaces ('cmux DEV <tag>.app'), so the shape lost its event word; the app path is now replaced before the split. ProcessKinds tests a hook helper inside the bundle. - aa-failures.json scenarios['0']: the workspace close rows were tested in the A/A (their intervals contain 0); they need a failing base only in an A/B. The record also says 59ea3fd adds under 1 ms per sampler tick during the load, judged too small to need a new A/A. selftest.py: 25 tests OK. * austins-day: the app path is replaced in the cli role's arguments too (note of the review of 771cc4f) A cmux CLI argument that holds a bundle path ('cmux DEV <tag>.app', with spaces) split at its spaces; the replace now runs on the arguments of both roles. Descriptive shapes only. selftest.py: 25 tests OK. * austins-day: time to first answer per acpmux chat; #28442 rows in the requested comparisons For the #28442 A/B (pa-b10, lead 2026-10-30): the driver records, for each acpmux request, when the first agent_message_chunk for it arrived (first_chunk_ms, read from the session/update notifications it already skipped). summarize: acpmux_first_answer_ms per chat = session/new ms + its prompt's first-chunk ms, for chats that got an answer; acpmux_answered_chats. Requested paired comparisons gain the 90 s agent_start time-outs, the first answer median and p95, and the answered-chat count (no verdict, n shown). No verdict input changes. Tests FirstAnswer (the summary, and the driver's own acpmux_call against a tiny ACP server). selftest.py: 27 tests OK. * austins-day: first answer over all chats (unanswered at their time to failure); labels; one time-out row (notes of the review of d965fcf) - Survivor bias: the first-answer rows over answered chats only are not comparable when the arms answer different numbers of chats. New row over all chats: an answered chat at its first-answer time, an unanswered one at the time it failed (its recorded steps, or the 120 s client time-out), a lower bound; the rows say which they are. - The 90 s agent_start time-outs are a verdict row; the duplicate requested row is gone. selftest.py: 27 tests OK. * austins-day: every unanswered acpmux chat ranks last at 150 s; chats still running at the wave limit are recorded (review of bcbeee4, CHANGES) Should-fix: 'time to failure' for an unanswered chat differed by failure path (a lost prompt after session/new counted as ~2 s, a refused connect as 120 s), so the all-chats first-answer rows could flip the sign of an A/B. Every chat with no answer now counts at one value above any answer, NO_ANSWER_MS = 150 s (the wave's join limit), so p50 and p95 are exact order statistics with no-answer last; rows relabeled. Test chats for a lost prompt after session/new and a refused connect. Note: a chat still running when its wave stops waiting was left out of the record (survivor bias for the slowest); the driver now records it as 'still running at the 150 s wave limit' (from a snapshot of the finished chats, so a late append is never read). selftest.py: 27 tests OK.
…ly what they answer (#28289) * bench(cmux-tui): agents load scenario for the daemon latency bench 80 fake terminal agents run the real hook helper (one durable journal append per turn event) and a CLI call in a loop, a churn client creates and closes terminals, and an app client sends what the cmux app sends (put-frontend-projection window saves, list-workspaces, session.snapshot, identify). It reports the app requests' latency, the requests over the app's 2 s timeout and both daemon locks by site, the shape of the 2026-10-29 report (#27698). Run: cmux-ci run --class exclusive --measurement --script scripts/measure/perf-10x-daemon.sh --ref SHA --arg=scenarios=agents Measurement only; no CI step, gate or budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cmux-tui): server-stats locks section: registry and state lock holders by site server-stats include=["locks"] reports the workspace registry lock and, new, the mux state lock: wait and hold histograms, the current holder, the last stall, and the top twenty acquiring sites by total hold and by p99 hold, each with its hold and wait p99 and maximum. Sites come from #[track_caller] (lock_state_pinned now carries it too, so its callers are named instead of the wrapper). Per site, two power-of-two histograms (256 bytes each) are updated under the site table lock that the release already took, while the lock is still held: no allocation after a site's first acquisition and no new contention. The state lock's existing rank and holds bookkeeping is unchanged. The section is optional (absent unless named), so older SDK decoders keep working; schema, bindings, api-compat surface and commands.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cmux-tui): name the callers of the registry and state lock wrappers in lock stats #[track_caller] on the Mux wrappers that take the workspace registry or the mux state lock (with_state, surface, resolve_resource_path, commit_state, commit_resource_mutation_plan, with_resource_projection, the resource effect helpers, tree_decorations and others), so server-stats locks names the request path that holds the lock instead of the wrapper's own line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): settling a late registry receipt must not lock state again (red) lock_state_pinned settles the late answers of indeterminate registry commits (cx-g1fa.3) while it holds the registry and state. The settle then ran publish_resource_event, whose pending-directory step locks state again: a lock-rank panic in debug builds and a self-deadlock of the whole daemon in release builds (std Mutex), on a path a loaded Mac reaches when a journal commit outlives its 30 s receipt wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): settling a late registry receipt publishes only the journal event The settle runs under the registry and state; publish_resource_event also publishes pending terminal directories, which locks state again (and then the registry). Publish the journal event only: subscribers still read the new head, and a directory still pending is published by the terminal's next output or the next committed graph change, as before the settle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): drop unnecessary path qualifications in the lock tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): no request holds state across a writer receipt, or the connection across an unsettled wait (red) Two holders that wait while they hold a lock (#27698 audit, lock waits p90 5 s / p99 17 s on a loaded Mac): - rename-workspace held the mux state lock across its journal writer receipt wait (up to the 30 s durable wait); - a request that found an earlier indeterminate commit waited for its answer (2 s) with the registry connection pinned, though the writer needs that connection to commit and answer: the wait could only run out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): no request waits for a writer answer while it holds state or the connection - rename-workspace releases the mux state lock before its journal writer receipt wait and takes it again to apply the committed name (found by workspace key). The registry stays held, so no topology writer interleaves; state readers see the old name until the commit is durable, instead of queueing behind the disk for up to the 30 s durable wait. - lock_state_pinned waits for the answers of earlier indeterminate commits before it pins the registry connection, and drain_unsettled never waits while this thread holds the connection: the writer needs the connection to commit and answer, so that wait could only run out (2 s) and fail the request. A commit still unanswered after the unpinned wait is refused at once, as it was after the pinned wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(cmux-tui): PinnedState has no unpin; no receipt wait under state With rename no longer holding state across its receipt, nothing unpins a held state any more: PinnedState always holds its connection pin, and commit_registry_intent_with loses its release_pin hook. A debug assert refuses a journal writer receipt wait while the thread holds the mux state lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): a rename's reply comes after its name is in state; readers see the old name only until then Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): move the mux into the settle test's pinning thread (clippy redundant_clone) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): server-stats locks formats its site labels after it releases the site table Every release of the registry and state locks takes the site table mutex; detail() now copies the raw records out and formats the file:line labels after the unlock (review of #28147). Schema text: the holder site names either lock, and sites counts the locations that released the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): reads run no write, one-collection reads read one collection, a pinned prepare refuses a late commit (red) #27698 lock work, design rule 1 (no write or I/O under the registry lock) and the next steps after #28159: - session.snapshot deleted stale notification read marks under the registry lock (a read that writes); the open must drop them instead; - workspace.list and session.ping built the whole session snapshot (topology, terminals, projections, journal head) for their workspaces or cursor; - a pinned prepare took a late answer that landed after its settle and built on state without it (#28159 review, Low 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): reads run no write; workspace.list and session.ping read only what they answer; a pinned prepare refuses a late commit Design rule 1 of the #27698 lock work: no write under the registry lock in a read. - The session snapshot no longer deletes read marks of notifications outside the retained window; it skips them, and the registry open drops them (the runtime prune queue does not survive a restart). - The snapshot no longer publishes pending terminal directories (a full terminal.cwd commit) before it reads; terminal output, resize, reconnect and every committed graph change publish them. - workspace.list builds the workspaces alone (workspace rows, the topology frame, workspace decorations) under the registry lock only, not the whole session snapshot under the registry and state; session.ping reads the topology frame for its cursor. One builder makes the workspace values for both the snapshot and the list. Under an 80-agent load the session snapshot was the registry lock's longest holder (resource_api.rs:551, 25.7 s held in 60 s, fleet step 73e93323); most of its calls were agents' list calls. #28159 review, Low 1 and Low 3: lock_state_pinned settles the late answers that arrived when it pinned, but an answer can land after that settle (the writer drops the connection, then sends its receipts). drain_unsettled now refuses a pinned caller while such an answer is unsettled instead of letting it build on state without the late commit; the next pin settles it. A rename whose workspace left state after a durable commit raises the in-memory revisions before it reports the error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): drop an unnecessary path qualification (clippy unused_qualifications) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): the durable restart test waits for lifecycle readiness, not for the socket named_server_status_stop_alias_and_durable_restart_preserve_topology sent server status and reload-config right after the socket accepted. The server answers ordinary requests from that moment but its lifecycle commands only once it is lifecycle-ready (Mux::mark_server_lifecycle_ready, 'Ordinary control clients may connect before this point'); before that they refuse with 'still starting' (exit 3). The test passed only while the workspace list in between took longer than the restore. With workspace.list now building only the workspaces (this branch), it failed once on a loaded host (fleet step 23b44075: reload-config exit 3, 'the local server is still starting'). It now waits for lifecycle_ready on identify after each start, the product's own readiness signal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): an adopted idle shell presents its reported directory after a restart (red) Review of #28289 (M1): the session snapshot no longer publishes pending terminal directories (a read writes nothing), and nothing else published the directory report a host replays at adoption: an idle shell after a daemon restart showed its launch directory until it printed or the graph changed (#12978's startup race). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): adoption publishes the shell's replayed directory; workspace.list reads under one connection hold Review of #28289: - M1: the host replays the shell's last directory and progress reports at adoption; they stay pending until the terminal is live. The session snapshot used to publish them on its first read; a read writes nothing now, so finish_terminal_adoption publishes them once the terminal is live, as a reconnect does. An idle shell after a daemon restart presents its reported directory, not its launch directory. - L1: workspace.list holds the registry connection for its whole read, as the snapshot's pin did, so no late writer commit lands between the workspace rows, the topology frame and the decorations. - The terminal_directory doc names who retries a pending report now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): keep a handle to the adopted surface to publish its replayed reports (E0382) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cmux-tui): an app terminal's first view publishes the directory its shell reported before it (red) Review of #28289 (fresh review, finding 1): a byte-backend shell can report its directory before the first view writes its durable record; the report stays pending, terminal.project's own publish finds the record still Launching, and finish_app_terminal_first_view commits Running with no publish after it. The session snapshot used to publish it on its next read; a read writes nothing now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): an app terminal's first view publishes its shell's pending directory and progress The first view commits the byte-backend terminal Running; a directory or progress report the shell made before the record existed stays pending until then. Publish it once the terminal runs, as adoption and a reconnect do (review of #28289, finding 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): the two unsettled-commit refusals say the request is retryable now Both refusals in drain_unsettled reach a client as an untyped operation_failed; until the typed busy error with retry_after_ms lands (B2, R9 R-L3), the message says the request is retryable and can be sent again at once, so an agent reading it is not misled into a hard failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): StateGuard holds its MutexGuard directly; no expect (crash ratchet) The state guard kept its MutexGuard in an Option only to record the release before the unlock, and its Deref used two expect() calls: the crash ratchet counted them (cmux-tui-core expect 190 -> 192, #28147's red 'cmux-next crash ratchet'). Fields drop after Drop::drop, so the release is recorded in the body while the lock is still held, with no Option. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cmux-tui): the lock site histograms index and count without a panic path (panic ratchet) diagnostics.rs: the per-site octave histogram indexed its bucket array and added with +=, which the Rust panic ratchet counts (cmux-tui-core indexing_slicing 854 > baseline 853; arithmetic_side_effects rose too; review of #28147 at c88bc3a). The bucket is now taken with get_mut, and the counts, the running sum, the p99 bound and the acquisitions use saturating or checked arithmetic. The values are unchanged: bucket 0 holds 0, bucket i holds 2^(i-1)..2^i - 1, the last bucket everything above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Summary
Under load, the cmux-tui session daemon could deadlock on its own mux state lock. When a registry commit outlives its 30 s journal receipt wait (a loaded Mac, #27698), its late answer is settled by the next request that calls
Mux::lock_state_pinned, and that request holds the workspace registry andmux.statewhile it settles. The settle ranpublish_resource_event. That function'spublish_pending_terminal_directoriesstep locksmux.stateagain. A debug build panics on the lock rank. A release build deadlocks the whole daemon (stdMutex), so every later request and every connection hangs.The settle now publishes only the journal event. Subscribers still read the new head. A terminal directory that is still pending is published by that terminal's next output or the next committed graph change, the same as before the settle existed.
This PR also makes the daemon's two big locks measurable by call site:
server-statswithinclude:["locks"]reports the workspace registry lock and, for the first time,mux.state. For each lock it gives wait and hold histograms, the current holder, the last stall, the number of sites, and the top 20 sites by total hold and by p99 hold. Each site row has its hold and wait p99 and maximum. Each site keeps two 256-byte power-of-two histograms, updated under the site table lock that the release already takes, while the lock is still held. After a site's first acquisition this costs no allocation. The section is optional, so older SDK decoders keep working. Schema, bindings, api-compat surface andspec/commands.mdare updated.#[track_caller](with_state,surface,resolve_resource_path,commit_state,commit_resource_mutation_plan,with_resource_projection, the resource effect helpers,lock_state_pinnedand others). The statistics now name the request path that holds a lock, not the wrapper's own line.cmux-tui/scripts/bench-daemon-latency.py --scenarios agentsis a diagnostic fleet bench, not a CI step. It runs 80 fake terminal agents (the real hook helper plus a CLI call), terminal churn, and an app client (window saves,list-workspaces,session.snapshot,identify). It prints the app requests' latency and the lock sites.#27780 (Austin) touches the same registry lock. The only file both PRs change is
mux/resource_effects.rs, at different functions; I noted the overlap there.Owner: PL-daemon (session cmux-b6), please review the daemon lock change.
Testing
Regression test:
settling_a_late_registry_receipt_does_not_relock_stateincmux-tui-core/src/mux/tests/writer_lock_order.rs. It holds an indeterminate receipt, answers it, and pins state from another thread; the pin must return within 5 s and run the owed state update.a551489abaea4cfd8815ecf7:scripts/ci/cmux-tui-rust-check.sh test settling_a_late_registry_receipt_does_not_relock_stateat 0f46ae7 (test only, no fix). It FAILED withlock order violation: acquiring mux.state (MuxState) while this thread holds mux.stateatmux/journal.rs:11, the deadlock path.settling_a_late_registry_receipt_does_not_relock_state(bd8f77630117abe7d6290e2d),lock_detail_reports_per_site_hold_and_wait_percentiles(e6b30baeb75b78399af62863),server_stats_include_reports_registry_and_state_lock_sites(197f5fab7581ddbebcc2fefb), clippy (a361a9dd9077dec93763fcd5). The PR's CI panic ratchet and crash ratchet pass.aa49f608737c04b58e23478f: it compiled, fmt and clippy pass. Its 11 failures are base reds, not caused by this PR: the CLI noun-first path (cmux-tui: git diff and checkpoint diff take --after-path, --patch-offset and --patch-revision (base red) #28119), the acpmux hermetic guard (acpmux tests: web_image_attachments runs hermetic, with node as its one host tool (base red) #28150), and 9 new feat-cmux-next reds (agents_chat_tools x5, reply_in_place, a_daemon_whose_home_is_replaced_stops, integrity_check_scope, the_known_params_are_the_macos_hosts), whose fix feat-tip-reds has in progress. d8f9ee9 adds only the histogram change on top of it.52b69830at 3b1cb41 and78f9e8ecat 100b8e3 each failed on the two base reds only.lock_detail_reports_per_site_hold_and_wait_percentilesandserver_stats_include_reports_registry_and_state_lock_sites.Observed / Not run
history_agent_fold::agent_history_save_does_not_wait_for_the_workspace_registry_lockfailed at its precondition (line 109, before it takes any lock): a read-after-write gap between the agent-hook reply and the journal ring. B3 design item 3 owns it. Alsoapps_store::the_customize_folder_installs_itself_and_reloads_when_it_changes(step ba4f8faf). I also ran theagentsbench at 6340c54 (statistics only, no fix; fleet step73e933233cd19e66b80674ea, aws-m4pro-9). It gave app requests p50 13 ms, p95 57 ms, p99 91 ms (1,868 requests, none over 2 s) and registry lock wait p99 82 ms. The longest registry holder isresource_api.rs:551, the snapshot readers: 25.7 s held in 60 s. These numbers rank the holders for the follow-up PRs; this PR claims no speedup.Mutexrelocking on one thread, which deadlocks or panics. Austin's-day numbers: pending harness owner. Perf claim: none; the statistics are instrumentation.Performance
No perf numbers: the lock statistics are instrumentation and the fix removes a deadlock; this PR claims no speedup.
Austin's-day A/B: pending, in the pa-d queue (harness owner). It runs with an A/A-valid harness sha; the first A/A pass is #28077 (comment). The A/B comment will be linked here.
Changelog
Fixed: The cmux NEXT session daemon no longer hangs when a busy Mac delays a save past its timeout.
Proof
No capture: daemon-only change, no UI.
Need help on this PR? Tag
@codesmith-botwith what you need.Summary by cubic
Fixes the cmux-tui session daemon deadlocking when a late registry commit receipt is settled while the registry and state locks are held. The settle previously called
publish_resource_event, which locks the state again — a self-deadlock in release builds and a lock-rank panic in debug. The settle now publishes only the journal event; a terminal directory still pending is published by the terminal's next output or the next committed graph change.Bug Fixes
settling_a_late_registry_receipt_does_not_relock_stateanswers an indeterminate receipt and pins state from another thread; the pin must return within 5 s.StateGuardnow holds itsMutexGuarddirectly, removing twoexpect()calls that tripped the crash ratchet.New Features
server-statswithinclude:["locks"]now reports both the workspace registry lock andmux.state: wait/hold histograms, current holder, last stall, and the top 20 sites by total and p99 hold.Muxlock wrappers get#[track_caller], so the reported site names the request path holding the lock, not the wrapper's own line.cmux-tui/scripts/bench-daemon-latency.py --scenarios agentsis a diagnostic fleet bench with 80 fake terminal agents, terminal churn, and an app client; it prints app-request latency and lock sites. Not a CI step.lockssection is optional, so older SDK decoders keep working; schema, bindings, andspec/commands.mdare updated.Written for commit c842b42. Summary will update on new commits.