Skip to content

cmux-tui: the daemon no longer deadlocks settling a late registry receipt; server-stats reports both locks by site - #28147

Merged
lawrencecchen merged 13 commits into
feat-cmux-nextfrom
fix/pa-b3
Oct 31, 2026
Merged

lawrencecchen merged 13 commits into
feat-cmux-nextfrom
fix/pa-b3

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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 and mux.state while it settles. The settle ran publish_resource_event. That function's publish_pending_terminal_directories step locks mux.state again. A debug build panics on the lock rank. A release build deadlocks the whole daemon (std Mutex), 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-stats with include:["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 and spec/commands.md are updated.
  • About 40 Mux lock wrappers get #[track_caller] (with_state, surface, resolve_resource_path, commit_state, commit_resource_mutation_plan, with_resource_projection, the resource effect helpers, lock_state_pinned and 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 agents is 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_state in cmux-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.

  • Red, fleet step a551489abaea4cfd8815ecf7: scripts/ci/cmux-tui-rust-check.sh test settling_a_late_registry_receipt_does_not_relock_state at 0f46ae7 (test only, no fix). It FAILED with lock order violation: acquiring mux.state (MuxState) while this thread holds mux.state at mux/journal.rs:11, the deadlock path.
  • At the head d8f9ee9 (feat-cmux-next merged, plus the Rust panic ratchet fix), each fleet run is done (passed): 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.
  • Full run at the merge head c88bc3a, fleet step 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.
  • Earlier heads: green 52b69830 at 3b1cb41 and 78f9e8ec at 100b8e3 each failed on the two base reds only.
  • New tests for the statistics: lock_detail_reports_per_site_hold_and_wait_percentiles and server_stats_include_reports_registry_and_state_lock_sites.

Observed / Not run

  • Observed: the red failure above, and the green run. Earlier full runs on this branch also showed reds this PR cannot reach. They passed when run again alone and are listed for a fixer, not cleared as flaky: history_agent_fold::agent_history_save_does_not_wait_for_the_workspace_registry_lock failed 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. Also apps_store::the_customize_folder_installs_itself_and_reloads_when_it_changes (step ba4f8faf). I also ran the agents bench at 6340c54 (statistics only, no fix; fleet step 73e933233cd19e66b80674ea, 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 is resource_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.
  • Not run: the deadlock was not reproduced in a release build on a live loaded Mac. The release behavior follows from std Mutex relocking 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.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with 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

  • Regression test settling_a_late_registry_receipt_does_not_relock_state answers an indeterminate receipt and pins state from another thread; the pin must return within 5 s.
  • StateGuard now holds its MutexGuard directly, removing two expect() calls that tripped the crash ratchet.
  • The lock-site histogram uses bounds-checked bucket access and saturating arithmetic so it stays under the panic ratchet baseline.

New Features

  • server-stats with include:["locks"] now reports both the workspace registry lock and mux.state: wait/hold histograms, current holder, last stall, and the top 20 sites by total and p99 hold.
  • ~40 Mux lock 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 agents is 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.
  • The locks section is optional, so older SDK decoders keep working; schema, bindings, and spec/commands.md are updated.

Written for commit c842b42. Summary will update on new commits.

View guided diff Turn on auto-fix

lawrencecchen and others added 8 commits October 30, 2026 06:32
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>
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 30, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Correctness review (independent subagent, given the diff and the PR body): SHIP

  • Deadlock real; fix complete. Before the fix, lock_state_pinned (registry and mux.state held) called settle_registry_receipts → publish_resource_event → publish_pending_terminal_directories, which locks mux.state again. After the fix, the settle takes only leaf mutexes (unsettled, journal_kernel.state, journal_event_epoch). No step takes state or the registry again. lock_state_pinned is the only caller of the settle.
  • A pending directory is late, not lost. Terminal output, resize and reconnect, session.snapshot, and every committed topology change still publish it. Before the fix, the publish at settle never finished.
  • The red test exercises the production path (hold_unsettled → lock_state_pinned → settle). Only the two hooks are #[cfg(test)].
  • Statistics are correct:
    • The p99 bucket math is right: the value is never above the maximum and is less than twice the true sample.
    • The release is recorded while the inner mutex is still held.
    • The LockStats mutexes never nest, so there is no new deadlock or rank panic.
    • Schema, commands.md and the api-compat surface match the serialized structs.
  • #[track_caller] (42 functions) changes only Location::caller().
  • The bench is not in any CI workflow, and its quoting is safe.
  • Non-blocking, carried into the follow-up PR:
    • detail() formats the site labels while it holds the sites mutex. This happens only on an include:["locks"] request.
    • Two schema doc nits: the holder site text names only the registry lock, and sites counts releases, not acquisitions.

Owner PL-daemon (cmux-b6): this changes the daemon's lock path, please take a look.

Review (claude-subagent): SHIP at 100b8e3
R9: pass (DETAIL_SITES = 20 and SITE_OCTAVES = 32 are report sizes. The PR adds no admission cap, queue bound or production deadline.)
perf claim: none

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 30, 2026
@blacksmith-sh

blacksmith-sh Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
main.DeveloperIdAppTests.test_download_prints_the_active_profile_only/
test_download_prints_the_active_profile_only
View Logs
stubs: /System/Library/Frameworks/Security.framework/Versions/A/Security has no stub/
Security has no stub
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_CFRetain']/
CoreFoundation, missing 1: ['_CFRetain']
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_log2']/
CoreFoundation, missing 1: ['_log2']
View Logs
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)/
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)
View Logs
test_the_ten_first_party_gpl_crates_are_named_exactly (main.AppCrateLicenseGate.tes
t_the_ten_first_party_gpl_crates_are_named_exactly)/
test_the_ten_first_party_gpl_crates_are_named_exactly (main.AppCrateLicenseGate.tes
t_the_ten_first_party_gpl_crates_are_named_exactly)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

lawrencecchen added a commit that referenced this pull request Oct 30, 2026
…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>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-subagent): SHIP at 100b8e3
R9: pass (DETAIL_SITES = 20 and SITE_OCTAVES = 32 are report sizes; no admission cap, queue bound or production deadline is added)
perf claim: none

The same verdict as the review comment above, with the receipt line first.

@lawrencecchen lawrencecchen added ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds and removed ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds labels Oct 30, 2026
@lawrencecchen lawrencecchen removed the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 30, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-subagent-fresh): SHIP at 100b8e3
R9: pass
perf claim: none

Fresh, independent correctness review. I read the code at the exact head through git show/git diff origin/feat-cmux-next...100b8e317c35, with the bindings spot-checked. I did not read earlier review comments before this verdict.

Deadlock: real, and fully fixed

  • Real. Mux::lock_state_pinned (mux/signaled_mutex.rs:371-385) holds the registry, the connection pin and mux.state, then calls settle_registry_receipts. On the base, when take_settled_receipts() is not empty, that called publish_resource_event -> publish_pending_terminal_directories (terminal_directory.rs:95-97), which calls self.state.lock() again on the same thread. In debug builds this is the lock-rank panic. In release builds std Mutex relocks on one thread and the whole daemon hangs. The path runs every time a late receipt is settled, not only sometimes.
  • Fixed. Settle now calls only publish_journal_event (effect_commit.rs:89). That function takes only leaf locks: journal_kernel.state (journal_kernel.rs:316-322) and journal_event_epoch. commit_effect_intent already called it while holding the registry. settle_registry_receipts has one caller (lock_state_pinned). The one production SettleState closure (workspace_rename.rs:214-229) only changes State and takes no lock. I checked other publish_resource_event callers next to state, e.g. resource_effects.rs:168-170 and 219-223, and state/commit.rs:47-65: each releases state (and the registry) first.
  • Terminal directory update: not lost, possibly published later. publish_pending_terminal_directories only retries reports whose directory_pending flag is still set, and settle does not clear that flag. A report stays pending and is retried by the public session snapshot (resource_api.rs:541), every committed topology change (publish_resource_event), and terminal output, resize and reconnect (surface/hosted_reader/*, local_output.rs:250). The removed call could never run without deadlocking, so it never worked, and removing it loses no behavior. Informational only: a quiet shell whose launch row came in through a late receipt shows its cwd at the next snapshot read or topology commit, not immediately.

locks statistics (diagnostics.rs, StateMutex)

  • No new deadlock. LockStats' holder, last_stall and sites mutexes are leaves: LockStats methods take them, and those methods take no other lock. debug_spans::mark_with uses a thread-local RefCell. server-stats locks reads stats().detail() and never takes the registry or mux.state.
  • Release ordering is correct. StateGuard::drop records the release and clears holder while it still holds the inner guard, then unlocks, then note_state_lock(false), then _rank drops. The lock-rank release still comes after the unlock. A thread-local hold count that is briefly too high on the same thread is harmless.
  • Poison path. A poisoned guard is still recorded and released. Deref uses expect, and the guard is None only inside drop.
  • OctaveHistogram math is correct. Bucket i holds 2^(i-1)..2^i-1, and p99 is the bucket's upper bound clamped to the maximum, so it is less than 2x the true value and never above the maximum.
  • Bounds. The sites map is keyed by &'static Location, so it can only hold as many entries as there are source locations. DETAIL_SITES = 20 limits the report size, not a resource, queue or deadline. Under R9, no new production cap, queue or deadline (the only new timeout is the test's 5 s recv_timeout).

Schema and spec

ServerStatsLocks, ServerStatsLockDetail and ServerStatsLockSiteDetail in spec/sdk-schema.json match the Rust structs field for field. holder and last_stall are required and nullable, and Rust serializes Option without skip_serializing_if. locks is optional and present only when include requests it (server_stats.rs:25). The thresholds in the constraints match LOCK_CONTENDED_THRESHOLD (1 ms) and LOCK_STALL_THRESHOLD (100 ms). Spot-checked: the Rust and Python bindings, and commands.md. The green run passed cmux-api-compat.

#[track_caller]

It does not change program semantics. It does change panic locations: an .unwrap()/.expect() or a lock-rank panic inside a wrapper now points at the wrapper's caller. The red log shows this (journal.rs:11:14 and not terminal_directory.rs). This is acceptable and arguably more useful. A few wrappers lock inside a closure (e.g. surface_workspace's or_else), so those sites name the wrapper line. That affects attribution only.

Bench script

cmux-tui/scripts/bench-daemon-latency.py was already on the base. The new agents scenario is not in the default --scenarios small,scale. No .github workflow references the script or scripts/measure/perf-10x-daemon.sh. It is not a CI step.

Non-blocking notes (no fix required)

  • Low, unmeasured overhead: each mux.state acquisition now calls Instant::now() about 4 times, takes the holder mutex 2-3 times, and does one sites map update. That update runs while state is held, so it extends every state hold by a short amount. The PR claims no speedup, so R10 does not apply. The agents bench 73e933233cd19e66b80674ea ran with the instrumentation in place but has no before/after.
  • Nit: diagnostics.rs:166 and the PR body say "256 bytes". OctaveHistogram is 32x8 + 16 = 272 bytes.
  • Nit: sites keys include the column, but labels are file:line, so two lock calls on one line give two rows with the same label.

Fleet logs read (cmux-ci log --full)

  • Red a551489abaea4cfd8815ecf7 at 0f46ae7 (test-only commit; checkout confirmed). settling_a_late_registry_receipt_does_not_relock_state FAILED with lock order violation: acquiring mux.state (MuxState) while this thread holds mux.state at mux/journal.rs:11:14, then the 5 s assertion "deadlocked or panicked". This is the deadlock path.

  • Green 78f9e8ec8657b35e1ba9ee88 at 100b8e3 (checkout confirmed). fmt and clippy -D warnings passed (165 s). Nextest: 12703 run, 12701 passed, 2 failed, 130 skipped; doc tests passed. The new tests all PASS: settling_a_late_registry_receipt_does_not_relock_state, lock_detail_reports_per_site_hold_and_wait_percentiles and server_stats_include_reports_registry_and_state_lock_sites. No other test failed. The 2 failures are outside this diff:

    • acpmux::hermetic_daemon_guard: tests/web_image_attachments.rs lacks HERMETIC, and the PR touches no acpmux file.
    • transport_path_coverage_tests::every_safe_transport_operation_has_a_noun_first_path: git.checkpoint.diff fields after_path, patch_offset and patch_revision, and the PR touches no checkpoint or CLI file.

    Both are base findings.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 51e2350: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr28147 -f ref=fix/pa-b3 -f platform=macos -f backend_mode=local

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).
@lawrencecchen lawrencecchen removed the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 31, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (Claude Opus 5.5 reviewer): SHIP at c842b42
reviewer-session: 4fb13519-fac9-4592-9954-a8497408f2ca
author-session: pa-b3-author
R9: pass (SITE_OCTAVES = 32 and DETAIL_SITES = 20 set the histogram and report sizes. The PR adds no cap on a resource, a queue or a production deadline. The only new timeout is the regression test's 5 s recv_timeout.)
perf claim: none

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)

  • mux/resource_effects/effect_commit.rs:89: the settle now calls publish_journal_event, not publish_resource_event. Before, lock_state_pinned (mux/signaled_mutex.rs:371-385) held the registry, the connection pin and mux.state, then publish_pending_terminal_directories (mux/terminal_directory.rs:95-97) locked mux.state again on the same thread. After the fix, the settle takes only leaf mutexes: journal_kernel.state (journal_kernel.rs:316), the maintenance control (mux/journal_maintenance.rs:94) and journal_event_epoch (mux/journal.rs:14-20). None of them takes the registry, the connection or state.
  • settle_registry_receipts has one caller (lock_state_pinned).
  • A pending terminal directory is published later, not lost: terminal output, resize and reconnect (surface/hosted_reader/*, surface/local_output.rs:250) and the next publish_resource_event retry it.
  • Regression test mux/tests/writer_lock_order.rs:165: it uses the production path (hold_unsettled, then lock_state_pinned, then settle). Only the two seams are #[cfg(test)] (journal_ingress/effect_intents.rs:42, workspace_registry/registry_intent.rs:196). The red fleet step a551489abaea4cfd8815ecf7 at 0f46ae7 is in the PR body.

Lock statistics (correct)

  • StateGuard (mux/signaled_mutex.rs:219-270): Drop::drop records the release while the inner guard is still held. Then the fields drop in declaration order: guard unlocks first, and _rank releases after the unlock. The poisoned path records and releases as well.
  • LockStats mutexes (holder, last_stall, sites) are leaves and never nest. detail() (diagnostics.rs:315) takes neither the registry nor state, so server-stats include:["locks"] cannot deadlock.
  • OctaveHistogram (diagnostics.rs:175-205): the bucket index is bounds-checked, the arithmetic saturates, and p99 is the bucket's upper bound, clamped to the maximum. There is no panic path, so the panic and crash ratchets pass.
  • Wire output: locks is skipped unless include names it (server/server_stats.rs:25, diagnostics.rs:724-726), so older decoders keep working.

Merge c842b42 (clean)

  • I compared the PR's net diff before the merge (d0d32d3a6e0..d8f9ee99c02) with its net diff after (HEAD^2..HEAD). Both diffs change the same 68 files. In every file outside bindings/, the +/- lines are identical, so no PR change was lost and no feat change was undone.
  • In bindings/, the files differ only because feat changed the schema too (new hashes and new generated content). python3 cmux-tui/bindings/codegen/generate.py --check passes for cpp, go, java, python, rust, typescript, zig and pageRouter.

Evidence

  • Fleet compile-check 37d91b3098752797cf3775ff at c842b42 shows done.
  • gh pr checks at this head: 25 pass, 30 skipping, 0 red. This PR causes no red.

Non-blocking

  • Every mux.state acquisition now calls Instant::now() about three times, takes the holder mutex twice, and makes one sites map update under state. The map update SipHashes the Location's file string. Nobody has measured this cost on the per-key paths. The PR claims no speedup, so nothing blocks on it. A follow-up bench of the agents scenario before and after would confirm the cost.
  • diagnostics.rs:165 says 256 bytes. OctaveHistogram is 272 bytes.

@lawrencecchen lawrencecchen added ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds and removed ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds labels Oct 31, 2026
# 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
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-subagent-merge-review): SHIP at 51e2350
R9: pass
perf claim: none

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 locks section from this PR and the hook_queue section from #28041)

  • diagnostics.rs ServerStatsSnapshot: locks: Option<LocksSnapshot> comes after write_path, then hook_queue: Option<HookQueueSnapshot>. Both fields have skip_serializing_if = "Option::is_none". The diff of this file against feat has the same size as the PR's own change to the merge base (+166/-15). Nothing from either side is lost.
  • server/server_stats.rs: both constants (LOCKS, HOOK_QUEUE) and both fields are present. locks is wants(LOCKS).then(|| mux.lock_detail_stats()). The hook_queue expression from feat did not change.
  • server/server_stats_tests.rs: server_stats_include_reports_registry_and_state_lock_sites is complete, and its closing brace is restored. server_stats_include_reports_the_hook_queue follows it and has its #[cfg(unix)] from feat. Each test is identical to its parent's version.
  • spec/commands.md: the include list is resource_projection, write_path, locks and hook_queue. The result shape has locks?:..., (with a comma) and then hook_queue?:.... The lock_detail/lock_site definitions and both prose paragraphs are present. The "reads atomics (and, for hook_queue, ...)" text from feat is kept.
  • spec/sdk-schema.json: against feat, the only additions are the locks field (optional, not nullable, after write_path), its constraint, the three types ServerStatsLockSiteDetail, ServerStatsLockDetail and ServerStatsLocks, and the include constraint with the four names. Against the PR parent, the only additions are feat's hook_queue field, its constraint and ServerStatsHookQueue. In the Rust structs, holder and last_stall are Option without skip_serializing_if, so they serialize as null. The schema has required + nullable: true for them, and this agrees. All site fields are u64/String, required.
  • spec/api-compat.surface.json: the automatic merge has both /data/locks/** (with holder, last_stall and last_stall/blocker nullable) and /data/hook_queue/**.
  • Bindings: python3 -I bindings/codegen/generate.py --check passes for cpp, go, java, python, rust, typescript, zig and the webviews page router.
  • I found no duplicate item and no missing item.

The PR as a whole

  • Deadlock fix (mux/resource_effects/effect_commit.rs:75-90): settle_registry_receipts runs from lock_state_pinned (mux/signaled_mutex.rs:384) while the registry and mux.state are held. Before the fix, it called publish_resource_event (mux/journal.rs:6). That function calls publish_pending_terminal_directories (mux/terminal_directory.rs:95), which does self.state.lock(). On one thread this relocks a std Mutex. The fix calls only publish_journal_event, which takes only the leaf journal_event_epoch lock. take_settled_receipts has no other caller, so this is the only path. The terminal-directory publication that is now skipped has other triggers: resource_effects.rs:223, resource_workspace.rs:233, terminal_move.rs:124, resource_api.rs:541 and the terminal's own output. Thus no state is lost; the publication is only deferred. The fix is correct.
  • Regression test settling_a_late_registry_receipt_does_not_relock_state: it uses hold_unsettled_for_test to keep an answered indeterminate receipt, then pins from a second thread. A panic or a deadlock fails recv_timeout (5 s). It also asserts that the owed settle ran. The PR body records the red result on the base (lock-rank panic at mux/journal.rs:11).
  • StateMutex/StateGuard (mux/signaled_mutex.rs:214-327): wait_started runs before the block. acquired runs after the acquire. released runs in Drop::drop before the MutexGuard field drops, so the release is recorded while the lock is held, and an older unlock cannot erase the holder record of the next holder. This is the same order as SignaledMutexGuard. The poisoned path and try_lock record the same way. LockStats takes only its own leaf mutexes (holder, last_stall, sites) and never takes state or the registry. Thus it adds no lock-order edge.
  • OctaveHistogram (diagnostics.rs:164-212): bucket i holds 2^(i-1)..2^i-1, and the p99 rank is ceil(0.99·n). The bound is clamped to max_us. All arithmetic is saturating or checked. sites grows only by &'static Location, so its bound is the number of call sites in the binary. DETAIL_SITES (20) limits only the report.
  • #[track_caller] on about 40 wrappers changes only the Location that is recorded. It does not change behavior.

Findings

No correctness finding.

  • Low (not blocking), diagnostics.rs:182-186: a sample of 2^31 µs or more (about 36 minutes) goes into the last bucket. That bucket's p99 bound (2^31-1) is then below the true value. The docs say "bucket upper bound". This applies only to holds or waits that last more than half an hour, where hold_max_us still shows the true value.
  • Note (not blocking): every mux.state acquisition now takes about four more uncontended leaf mutex operations and one HashMap lookup on release. The release work occurs inside the state hold. The PR claims no speedup, and I expect this cost to be about 0.1 µs, far below the 10 ms action budget. No measurement of this overhead is attached.

Fleet evidence at 51e2350

  • Compile: 3f6e8bcf32f8c46dd9346b33 is done. CmuxNext, app and cmux-tui have ok: true (I read the controller log).
  • settling_a_late_registry_receipt_does_not_relock_state (the deadlock red test): 290d1a83dc69b37fee962a02 done, 1 test run: 1 passed (PASS mux::tests::writer_lock_order::...).
  • lock_detail_reports_per_site_hold_and_wait_percentiles: 5a591e100d4ac31c43b420ca done, 1 test run: 1 passed.
  • server_stats_include_reports_registry_and_state_lock_sites (exercises the merged server_stats.rs): c37071d49c4d1fa15e57ec5f done, 1 test run: 1 passed.
  • Not run at this head: feat's server_stats_include_reports_the_hook_queue (no fleet job named it; the resolution leaves its body byte-identical to feat's), and a full cmux-tui test run.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 31, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Checks at the head 51e2350 (pa-b3), gh pr checks 28147, one row per red. None is this PR's own:

check cause class evidence
cmux-tui spec inventory / clippy (changed cmux-tui packages) Rust panic ratchet RED: cmux-tui-core indexing_slicing 856 > 853 and string_slice 57 > 52, at agent_hooks.rs:1013-1015, agent_message_prompt.rs, apps/, browser_, fs_ops, git_ops, launch_credential.rs, mux/presentation.rs. The warnings also include acpmux, cmux-tui, cmux-wg base the same check is red with the same counts on the feat-cmux-next tip (push run 41158092570 at a112726, job 124410703378: indexing_slicing 856 > baseline 853, string_slice 57 > baseline 52). None of the named lines is in a file this PR changes; this PR's own histogram hits were removed in d8f9ee9
rust workspaces / Native/OptChat/optchat-chief memory_sync_state::tests::unasked_detect_never_lists_downloads_or_desktop panicked (memory_sync_state.rs:461) base the same test fails on an unrelated PR (lane/ao-takeover-22030, run 41159553538). This PR does not touch Native/OptChat; the job runs because the cmux-tui crates changed
rust workspaces wrapper of optchat-chief wrapper
cmux-next guards / tooling-tests test_the_ten_first_party_gpl_crates_are_named_exactly: the list now has cmux-chats-mirror; plus no ACTIVE profile named 'cmux NEXT Developer ID' base red on the feat tip (guards push run 41157955814 at ef2c8ee, job tooling-tests)
cmux-next guards / release-notary GitHub API rate limit exceeded for installation (POST 403, backoff to 300 s) infrastructure, also red on the feat tip the same job is red on the feat tip (run 41157955814)
nightly-guards / Guard status wrapper of the guard jobs wrapper
pr-red-checks / comment cancelled wrapper
PR checks / link GitHub API rate limit exceeded for installation infrastructure reran once on the same sha (run 41157832409)

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.

@lawrencecchen
lawrencecchen merged commit babe9dc into feat-cmux-next Oct 31, 2026
209 of 220 checks passed
@lawrencecchen
lawrencecchen deleted the fix/pa-b3 branch October 31, 2026 02:32
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…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.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…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).
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…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>
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
… 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>
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…-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.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…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>

This branch was previously deployed

3 inactive deployments
artifacts — 51e23501 Deployed Oct 31, 2026 by lawrencecchen via add the Windows daemon to the tree #86562
sentry-dsyms — 51e23501 Deployed Oct 31, 2026 by lawrencecchen via upload cmux-tui dSYMs to Sentry #86562
ci-routing-agent — 51e23501 Deployed Oct 31, 2026 by lawrencecchen via cmux-tui tree preflight #86562
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants