Repository navigation
cmux-tui: each terminal gets a fair share of the terminal connection lane (on top of #27719's 48-of-64 cap) - #28343
Conversation
…oad (red, #27698 B1) Austin's nightly-next (2026-10-29): about 80 terminal agents held all 64 daemon connections, 55,162 were refused, the app's among them. Red tests, with behavior-neutral seams (ConnectionBudget at the old 64, ConnectionLane/ConnectionSlot, app_lane_socket_path) so they compile on the base: - an_app_connection_is_served_while_the_session_socket_is_full - one_terminal_opening_many_connections_cannot_starve_another - the_connection_budget_follows_the_open_file_limit - a_client_that_stops_reading_for_a_while_keeps_its_connection - AppLaneConnectionTests (Swift): the app connects on <socket>.app
…res; budget from RLIMIT_NOFILE; no 2 s write kill (#27698 B1) Austin's nightly-next (2026-10-29): about 80 terminal agents held all 64 daemon connections; 55,162 were refused in 2.5 h, the app's among them. The Austin's-day A/B on #28159 showed 64/64 with ~3,700-4,250 refusals per 6-min run. - The app's own socket, `<session socket>.app` (app_lane_socket_path), bound under the same start lock. Each socket has its own budget, so terminals, agents, hooks and the CLI (session socket) can never take the app's connections. The app (LineTransport) connects there first and falls back to the session socket (older daemon, stale file, long path). Chromium: the browser's own IPC channels never compete with renderer connections (render_process_host_impl.cc gives each renderer its own). - A fair share per client on the session socket: the client is the peer's process session (getsid), so a terminal's shell, agent, hooks and cmux calls count as one. A client may take a connection only while it then holds fewer than the socket has left free: one terminal stops at half the lane, and every new client still finds room. Remote entry and WebSocket clients keep the lane budget only (no identity at accept). - The budget comes from the soft RLIMIT_NOFILE the owner already raises at start (owner_start, raise_open_file_limit: hard limit, at most kern.maxfilesperproc / OPEN_MAX): half of the descriptors left after start, 8 per slot (4 descriptors per connection, 2 connections per slot with the hung-up pool), half per socket; on macOS also bounded by kern.num_taskthreads (2 threads per connection). 64 and MAX_SERVER_CONNECTIONS are gone; the parked-connection bound follows it. - No write deadline: STREAM_WRITE_TIMEOUT (2 s) closed a connection whose client read late (a stalled app main thread lost its connection and resynced). Now a client is ended only when its replies overflow the bounded control queue (QueuedSink evicts and shuts the socket); the WebSocket ping never waits on a peer that does not read, and the ping clock shuts a dead peer's socket. Replaces the stopgap split of #27719 (48/64 for terminals).
…not a 3 s sleep (R5, #27698 B1) StalledSocket models the kernel in memory: a daemon write waits while the client's buffer is full, and one that waits when the test declares the stall fails as SO_SNDTIMEO does if a write deadline is set (red on the base: 2 s STREAM_WRITE_TIMEOUT), else keeps waiting (green). The app-lane test reads with no deadline: the daemon answers or refuses with one line.
|
PL-daemon (owner of |
|
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)
red: cmux-next checks (god files, concurrency, crash safety, l10n); base red: the same failure on the base 9b4e5a5; does not block Shipped in:
|
|
Found 4 test failures on Blacksmith runners: Failures
|
… prints its own prefix first) (#27698 B1) the_connection_budget_follows_the_open_file_limit failed on both the red and the green fleet runs (16894a02, 3830e38f) at connection_lanes.rs:123: the child printed "connection-budget=30" after libtest's "test <name> ... " on the same line, and the parser only took a line that starts with it.
|
Review (independent review subagent, Claude Opus 5.5): FIX at 6aaecf6 Findings
Checked and fine
|
… .app first; clippy (review of 6aaecf6, #27698 B1) Review FIX (landing/reviews/6aaecf6a9207...md): - Blocker: clippy -D warnings on changed files: SynchronizedTcpStream:: set_write_timeout is dead with no write deadline; same-type casts, unnecessary qualifications and a redundant clone in the tests. - The two sockets shared one RenderService, whose outbound byte budgets a session-socket client that stops reading now holds until its queue overflows (no 2 s write kill). The app's replies could then fail on a full pool and the app's connection close: #27698 by another path. The app socket now has its own RenderService. - cleanup removes <socket>.app before the session socket, so a daemon that binds right after the first removal keeps its fresh .app.
… outbound bytes (review of 6aaecf6, #27698 B1) Regression test for the app socket's own RenderService (0a0796c): spend the session socket's outbound and control budgets, as clients that stopped reading hold them now that no write deadline frees them; a session-socket identify then gets no reply (the test checks it proves something), and the app socket's identify is still answered. ServedSession::new builds the session's two budget sets in one place. Red proof: cp-scratch/pa-b1-red-budget (6aaecf6 + this test, with ServedSession::new serving both sockets from one RenderService).
…on starts without its app socket (red, #27698 B1) Lead review of 5631798, two defects for Austin's load: - every_lane_admits_a_client_at_the_smallest_budget: at the smallest budget (one connection per lane) the fair share refuses every client. - the_session_socket_serves_when_the_app_socket_cannot_be_bound: a directory nobody may remove at <socket>.app makes the whole daemon fail to start.
…ane has room; the daemon starts without its app socket (#27698 B1) - The fair share admitted a further connection only while the client then held fewer than the lane had left free, so a lane of one (the smallest budget an fd limit derives) refused every client. A client's first connection now gets in whenever the lane has room. - A failure to bind <socket>.app (a path that can't be removed, Windows) failed the whole start. The app socket is an addition: the daemon now logs why and serves the session socket alone; the app's LineTransport already falls back to it.
|
Review (independent review subagent, Claude Opus 5.5): SHIP at bfff9fd Status of the first review's findings (6aaecf6)
The lead's two delta fixes are correct:
R-L2 judgment: pass
New notes (none block)
Checked and fine
|
…26-11-01 05:20Z) The app socket, the RLIMIT_NOFILE budget and the removal of the 2 s write stall leave this PR: #27719 (fbb9e39) already keeps 16 of the 64 connections for the app by capping terminal clients at 48, and the lead chose to build on that cap. The tree is feat-cmux-next again here; the next commits add only the per-terminal fair share inside the terminal lane.
…thers (red) Builds on #27719's 48-of-64 terminal cap (lead decision 2026-11-01 05:20Z). The accept loop already knows which terminal a peer runs in (its process session); this commit carries that id to the terminal claim and the lease, with no behavior change, and adds the test. Red here: terminal 101 takes all 48 terminal connections and terminal 102 finds none.
…nal lane On top of #27719's 48-of-64 terminal cap (lead decision 2026-11-01 05:20Z): a terminal's first connection gets in whenever the lane has room; a further one only while that terminal then holds fewer than the lane has left free. One terminal opening connections in a loop stops at 24 of the 48, and every other terminal still finds room. The share and the count change together under one lock; a hung-up client's reclaim gives back its terminal's share too. A connection whose terminal is unknown keeps the lane bound alone.
|
Review (claude-review-subagent-cw): SHIP at 8fc1e8a Scope: Correctness (pass)
Findings that do not block
|
…e is cleared; the share is "no more than" the free lane (review 6553892057)
|
Review (claude-review-subagent-cw): SHIP at 20f7f9c Scope: The earlier findings are now closed
Small points, which do not block
|
|
Fleet evidence at 20f7f9c (rescoped: a fair share per terminal on top of #27719's cap; see the description):
|
…accept loop asks terminal_of_peer) clippy step b7e7a3f2c43e at 8fc1e8a: method contains_peer is never used.
|
Review (claude-review-subagent-cw): SHIP at d0db479 Scope: Dead-code checkThe fix is correct: at this head, only the tests in I examined each item that the PR adds or changes. I found no other item that has become dead:
Small points, which do not block
|
There was a problem hiding this comment.
3 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="cmux-tui/crates/cmux-tui-core/src/server/bounded_outbound.rs">
<violation number="1" location="cmux-tui/crates/cmux-tui-core/src/server/bounded_outbound.rs:106">
P2: `client: Option<u32>` lets a caller pass `None` and take a terminal slot with no fair-share limit, which contradicts the rule that peers without a readable PID stay outside the terminal lane. Make `client` a `u32`, so the no-PID case has to use `claim_watched_connection`, and update the test at outbound_and_scheduler.rs:393-401 accordingly.</violation>
</file>
<file name="cmux-tui/crates/cmux-tui-core/src/diagnostics.rs">
<violation number="1" location="cmux-tui/crates/cmux-tui-core/src/diagnostics.rs:707">
P2: This condition strands terminal-lane capacity once multiple terminals have leases: with two terminals holding 23 each, neither can claim either remaining slot. Compare the client count with its half-lane share so two terminals can use their fair shares.</violation>
<violation number="2" location="cmux-tui/crates/cmux-tui-core/src/diagnostics.rs:718">
P2: `release_terminal` updates the client map under its mutex but decrements `terminal_active` after unlocking, so a concurrent reconnect can observe the old lane count and be refused spuriously. Keep both updates under the same lock for known clients.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| pub(super) fn claim_watched_terminal_connection( | ||
| connections: &Arc<crate::diagnostics::ConnectionStats>, | ||
| stream: &dyn transport::Stream, | ||
| client: Option<u32>, |
There was a problem hiding this comment.
P2: client: Option<u32> lets a caller pass None and take a terminal slot with no fair-share limit, which contradicts the rule that peers without a readable PID stay outside the terminal lane. Make client a u32, so the no-PID case has to use claim_watched_connection, and update the test at outbound_and_scheduler.rs:393-401 accordingly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui-core/src/server/bounded_outbound.rs, line 106:
<comment>`client: Option<u32>` lets a caller pass `None` and take a terminal slot with no fair-share limit, which contradicts the rule that peers without a readable PID stay outside the terminal lane. Make `client` a `u32`, so the no-PID case has to use `claim_watched_connection`, and update the test at outbound_and_scheduler.rs:393-401 accordingly.</comment>
<file context>
@@ -91,25 +91,37 @@ pub(super) fn claim_watched_connection(
pub(super) fn claim_watched_terminal_connection(
connections: &Arc<crate::diagnostics::ConnectionStats>,
stream: &dyn transport::Stream,
+ client: Option<u32>,
) -> Option<ConnectionPermit> {
- claim_connection_watching(connections, watch_peer(stream), true)
</file context>
| pub fn release_terminal(&self, client: Option<u32>) { | ||
| if let Some(client) = client { | ||
| let mut clients = self.terminal_clients.lock().unwrap_or_else(PoisonError::into_inner); | ||
| if let Some(held) = clients.get_mut(&client) { | ||
| *held = held.saturating_sub(1); | ||
| if *held == 0 { | ||
| clients.remove(&client); | ||
| } | ||
| } | ||
| } | ||
| self.terminal_active.fetch_sub(1, Ordering::AcqRel); |
There was a problem hiding this comment.
P2: release_terminal updates the client map under its mutex but decrements terminal_active after unlocking, so a concurrent reconnect can observe the old lane count and be refused spuriously. Keep both updates under the same lock for known clients.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui-core/src/diagnostics.rs, line 718:
<comment>`release_terminal` updates the client map under its mutex but decrements `terminal_active` after unlocking, so a concurrent reconnect can observe the old lane count and be refused spuriously. Keep both updates under the same lock for known clients.</comment>
<file context>
@@ -686,7 +693,38 @@ impl ConnectionStats {
+ true
+ }
+
+ pub fn release_terminal(&self, client: Option<u32>) {
+ if let Some(client) = client {
+ let mut clients = self.terminal_clients.lock().unwrap_or_else(PoisonError::into_inner);
</file context>
| pub fn release_terminal(&self, client: Option<u32>) { | |
| if let Some(client) = client { | |
| let mut clients = self.terminal_clients.lock().unwrap_or_else(PoisonError::into_inner); | |
| if let Some(held) = clients.get_mut(&client) { | |
| *held = held.saturating_sub(1); | |
| if *held == 0 { | |
| clients.remove(&client); | |
| } | |
| } | |
| } | |
| self.terminal_active.fetch_sub(1, Ordering::AcqRel); | |
| pub fn release_terminal(&self, client: Option<u32>) { | |
| if let Some(client) = client { | |
| let mut clients = self.terminal_clients.lock().unwrap_or_else(PoisonError::into_inner); | |
| if let Some(held) = clients.get_mut(&client) { | |
| *held = held.saturating_sub(1); | |
| if *held == 0 { | |
| clients.remove(&client); | |
| } | |
| } | |
| self.terminal_active.fetch_sub(1, Ordering::AcqRel); | |
| } else { | |
| self.terminal_active.fetch_sub(1, Ordering::AcqRel); | |
| } | |
| } |
| let free_after = limit.saturating_sub(active.saturating_add(1)); | ||
| if held > 0 && held.saturating_add(1) > free_after { |
There was a problem hiding this comment.
P2: This condition strands terminal-lane capacity once multiple terminals have leases: with two terminals holding 23 each, neither can claim either remaining slot. Compare the client count with its half-lane share so two terminals can use their fair shares.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui-core/src/diagnostics.rs, line 707:
<comment>This condition strands terminal-lane capacity once multiple terminals have leases: with two terminals holding 23 each, neither can claim either remaining slot. Compare the client count with its half-lane share so two terminals can use their fair shares.</comment>
<file context>
@@ -686,7 +693,38 @@ impl ConnectionStats {
+ }
+ if let Some(client) = client {
+ let held = clients.get(&client).copied().unwrap_or(0);
+ let free_after = limit.saturating_sub(active.saturating_add(1));
+ if held > 0 && held.saturating_add(1) > free_after {
+ return false;
</file context>
| let free_after = limit.saturating_sub(active.saturating_add(1)); | |
| if held > 0 && held.saturating_add(1) > free_after { | |
| if held > 0 && held.saturating_add(1) > limit / 2 + limit % 2 { |
|
Fleet evidence at d0db479 (
|
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Fixes #27698 (the per-terminal part)
Summary
Rescoped (lead decision 2026-11-01 05:20Z: "build on it"). #27719 (fbb9e39) is on feat-cmux-next. It caps connections from processes inside the session's terminals at 48 of the daemon's 64, so 16 stay for the app. This PR keeps that cap and adds only a fair share per terminal inside the terminal lane. Before this, one terminal (an agent's hooks in a loop, a script running
cmuxin a pane) could take all 48 terminal connections, so every other terminal's agents were refusedserver.busy.getsid), which cmux-tui: agents in terminals hold at most 48 of the daemon's 64 connections #27719's accept loop already reads (TerminalSessions::terminal_of_peer, the same match ascontains_peer). A terminal's shell, the agent in it, its hooks and acmuxrun in it count as one client.ConnectionStats::claim_terminal_slot), so two claims never both take the last place a share allows. A hung-up client's reclaim (cmux-next: GUI split snaps back when the terminal starts late on a loaded Mac #27669) gives back its terminal's share too: the lease records its terminal.Out of this PR now (the earlier design, see the history): the app's own socket, the
RLIMIT_NOFILEbudget, and the removal of the 2 s write stall. #27719 covers the app lockout. The others can come back as their own PRs if the A/B data asks for them.History: 21646a4 merges feat (conflicts take feat's side). 6d91a64 reverts this branch's earlier design to feat. Then the three commits below are the change (red test, fix, test and doc follow-up from the review).
Changelog
Fixed: one terminal opening many connections to the session daemon no longer takes every terminal connection; other terminals' agents still connect.
Testing
server/tests/outbound_and_scheduler.rs::one_terminal_cannot_take_the_terminal_lane_from_the_others: terminal 101 claims connections in a loop; it must stop at most at half the lane, terminals 102 and 103 must each get one, and the app's slots must be untouched.cmux-tui-rust-check.sh test one_terminal_cannot_take_the_terminal_lane, macOS).test server::c74aa48508c5 and clippy b7e7a3f2c43e in a comment.Observed / Not run
No capture: daemon change, no UI.
Owner: PL-daemon (session cmux-b6) owns
cmux-tui-core.