Repository navigation
acpmux: a shared, paced full session list for every client (3 stale clients 82% -> 4% of a core) - #26479
Merged
Conversation
…ents cost ~4% of a core, not 63%) The daemon builds the full session list (`_acpmux/sessions`, `_acpmux/watch`, ACP `session/list`) once per change and window and shares the serialized bytes; pages and filters are cut from the same build. Builds are paced to about 1/32 of a core, and each connection (and all of them together) to a few lists per window, so a client that reconnects for every list can no longer hold the daemon busy. A list is never reused after a change a watcher hears of, an attach, or a peer row write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
Collaborator
Author
|
Owned by an agent session · session |
|
Found 4 test failures on Blacksmith runners: Failures
|
Collaborator
Author
|
Bead |
teamleaderleo
temporarily deployed
to
artifacts
October 28, 2026 00:57 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
sentry-dsyms
October 28, 2026 00:57 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
artifacts
October 28, 2026 01:02 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
artifacts
October 28, 2026 01:09 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
artifacts
October 28, 2026 01:12 — with
GitHub Actions
Inactive
…es fairly
Review fixes:
- A reply is queued only if no session_changed notice went to the
connection since its list was read (checked under the outbound lock);
it is read once more, then sent with _acpmux/lagged {watch: true}.
- A request shares any build begun after it arrived.
- A list request that would wait over 30 s is refused (list.paced,
retryable, retryAfterMs) instead of queueing without bound.
- A page costs at least 1/16 of a list; the first list is built before
it is paced, so the global reply pace holds from the first request.
- daemon_memory budget 166 MB (base 158-160, this PR 160-164).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 04:37 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 04:38 — with
GitHub Actions
Inactive
…eer states Review round 2: - A build begun after a request arrived is shared only once every chat log is read. - The snapshot key includes each peer's link state, which every peer row's peerState shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 06:14 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 06:17 — with
GitHub Actions
Inactive
Review round 3: the peers' link states in the snapshot key are the sorted (name, state) pairs, not a hash sum. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 07:21 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 07:23 — with
GitHub Actions
Inactive
…nseen
Review round 4: a list reply that waits for room in a full outbound
queue goes from a task; once it is queued, a notice counted since the
list was read sends _acpmux/lagged {watch: true} after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 08:24 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
ci-routing-agent
October 28, 2026 08:24 — with
GitHub Actions
Inactive
teamleaderleo
temporarily deployed
to
sentry-dsyms
October 28, 2026 08:45 — with
GitHub Actions
Inactive
teamleaderleo
had a problem deploying
to
artifacts
October 28, 2026 08:45 — with
GitHub Actions
Failure
teamleaderleo
marked this pull request as ready for review
October 28, 2026 08:57
Contributor
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
|
Contributor
|
Shipped in:
|
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Asked: "build the daemon-side guard: cache, or coalesce, full session-list replies per client and rate-limit them, so a stale or buggy client can never cost more than a few percent of a core again. Make it a system guard, not a one-off; a test drives one client at 1 Hz full-list requests and asserts bounded CPU. Codex xhigh review, then land."
Problem
A dogfood Mac's idle acpmux daemon sits at 30-41% of a core. Three cmux-tui servers built before #26255 reconnect about once a second and ask
_acpmux/watchfor the whole session list each time. In a 10 s sample of the daemon, about 36% of busy time goes to building every session summary, 44% to serializing and cloning the reply, and 12% to freeing it. #26255 fixed that client, but any old or buggy client can still do the same thing: every full-list request rebuilds and reserializes the whole list.Fix: one guard in the daemon, for every client
New
hub/list_guard.rsandserver/list_reply.rs._acpmux/sessions,_acpmux/watchand ACPsession/listall read the list through the guard. No client name is special-cased.One build, shared. A snapshot is the list serialized once (
{"sessions":[...]}). A whole-list reply goes out as those bytes: no JSON tree and no deep copy per request. A page (limit/cursor) or a filter (peer/status) is cut from the same bytes. Each row keeps itssessionId,updatedAt,peerandstatus, andsession_pages::session_list_replypicks the rows by them, so paging the whole list costs no extra build. The bytes are identical to the old replies; unit tests compare them byte for byte. A peer still gets masked summaries (peer_text.rs); the masked text is also made only once per build.Never stale for a change a client is owed. A snapshot is reused only while nothing a watcher hears of has changed since it was built. Three kinds of change count:
Eventswrapper that counts sends);peerState).It is reused only within its window, and never while the chat logs are still loading. A request that finds the snapshot out of date gets a build that starts after the request arrived. Fields that change with the clock alone (
stalledSince, an expiring tag) are at most one window old._acpmux/watchstill turns the watch on before it reads the list.A watcher never gets a list older than a notice it was sent. The reply is queued only if no
session_changednotice was queued on that connection since the list was read. The check holds the connection's outbound lock, which every notice takes too. If a notice got there first, the list is read again once. If a notice overtakes it a second time, or the reply had to wait for room in a full outbound queue and a notice went first, the reply goes out followed by_acpmux/lagged {watch: true}, which tells the client to read the list again.Paced. Up to 4 builds run back to back, then one per window. The window is 32 times what the last build took, capped at 15 s. Full-list builds then take about 1/32 of a core however many clients ask. A request shares any build that began after it arrived (once every chat log is read), so while chats keep changing, everyone waiting shares the next build.
Per connection, and all connections together. One connection reads up to 2 lists back to back, then one per window. All connections together read up to 8 per window; that covers a client that opens a new connection for each list. A page costs its share of the rows (
limit/ rows), so reading every page once costs one list. A page always costs at least 1/16 of a list, because finding it reads every row's key. The first list is built before any pacing, so the pace is known from the first request on. A request that would wait more than 30 s is refused with a retryablelist.pacederror that carriesretryAfterMs, so a flood never builds an unbounded queue.Timers and caches, with their stop conditions:
daemon_idlestill passes.Memory. The guard keeps the list it just sent, serialized, for its window. In one job, five runs of
tests/daemon_memory.rsread 158-160 MB on base and 160-164 MB with this PR, so its budget goes from 160 to 166 MB, with the reason in the test. The build copies what it keeps only after the summaries are freed, so glibc can give the summaries' heap back.Before / after CPU (same harness, same job)
Linux 32 vCPU cmux-ci job at the final head (run 40645955696):
acpmuxdaemon over 2,800 seeded idle chats (a 14.5 MB list, like the dogfood Mac's 14.7 MB);_acpmux/watch, read the reply, hang up, again a second later;/proc/PID/statover 180 s.Base is
feat-cmux-next489e697; after is this PR. Only this PR's files differ between the two runs.Base read 47-83% across five jobs on this harness; the guard read 3.7-4.5% in each. The base number moves between jobs; the guard's stays at about 4%. The clients still get their one list per second: the guard paces them, and none waits anywhere near 30 s, so none is refused.
Test
tests/daemon_list_guard.rsruns the real daemon binary over 400 chats with summaries of the real size (about 5 KB each). It measures the daemon's process CPU over 15 s per scenario and asserts at most 8% of a core in each. It then checks the replies are still correct:Base and an earlier head in one job (run 40609013380), and the final head (run 40650191654); cmux-ci, Linux, debug build:
hub_integration511 passed,peer_integration50 passed,daemon_idleanddaemon_memorypassed;cargo clippy -p acpmux --all-targets -D warningsis clean.agent_tools::tests::own_servers_run_under_the_daemon_prefixfails on base too, so it is not this PR's.cli::codex_auth_brokerfailed once in some runs and passed in others; this PR does not touch it.Review
An independent Codex reviewer (gpt-6-astra; xhigh for round 1, high after) went five rounds. It said SHIP, no findings at the head, 768b4cb. At that head, PR CI clippy (changed cmux-tui packages), rustfmt and rust workspaces are green, and so is a Linux cmux-ci job. Its earlier findings, all fixed:
peerStatewas missing from the key, and then was keyed by a hash sum where it needed an exact value;Risk
Medium: every full-list read now goes through the guard.
list.pacederror (withretryAfterMs) instead of a list.Overlap
pr-overlaplists open PRs that touch the same files (dispatch_request,server/mod.rs), none with the same fix: #25189, #26127, #26203, #24797, #22030. #23051 (open) removes a different per-reply copy inrpc.rs; this PR does not touchrpc.rs.Takes back #25054: only in form. The
_acpmux/sessionsarm moves intoserver/list_reply.rs, and itspeer/statusfilters (#25054) are kept: they are cut from the shared list by the samesession_list_reply.Size: one guard, its two callers and its daemon test: about 1,000 changed lines, about 420 of them tests. It stays one PR because the guard, its reply path and the test that fails on base only mean something together.
Agent session: agent session
🤖 Generated with Claude Code