Repository navigation
cmux-tui: each connection draws its replies from a share of its socket's budget (#27698 follow-up) - #28408
cmux-tui: each connection draws its replies from a share of its socket's budget (#27698 follow-up)#28408lawrencecchen wants to merge 13 commits into
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.
… 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.
… .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.
…et clients of the shared budget (red, #27698 follow-up) Second review of #28343, note 1: with no write deadline, a session-socket client that stops reading (a frontend stopped with Ctrl-Z) holds its queued replies' bytes from the session socket's shared outbound budget until it reads or hangs up. When that budget can't fit a reply, the next client is refused (its reply can't be made) and an idle client too. a_stalled_client_never_starves_the_other_clients_of_the_shared_budget: a 64 KiB session budget (test-only ServedSession constructor), a client that asks for far more and never reads; after its connection ends (the daemon's ClientDetached event, no timer), the next client and an idle one must still be answered.
…client that stopped reading (#27698 follow-up) With no write deadline (#28343), a session-socket client that stops reading holds its queued replies' bytes from the socket's shared outbound budget until it reads or hangs up, so other terminal clients could starve. Each local connection now registers with its socket's budgets (StalledEviction, held to the connection's end). When a reply can't fit, OutboundByteBudget ends the holder with the most queued bytes whose client takes no more, then tries once more. Ending it drops its queued replies (their bytes go back) and shuts its socket, so its writer and reader end. Who is stalled is the kernel's signal, not a timer: the socket's send buffer is full (transport::Stream::send_buffer_full, a zero-timeout poll for POLLOUT). A client that reads is never ended this way. The app socket has its own budgets, so this never touches the app for a terminal client. Same idea as BoundedOutbound's largest-stream eviction inside one connection, one level up.
|
PL-daemon (owner of |
|
Review (independent review subagent, Claude Opus 5.5): FIX at c337820 Scope: PR #28408, diff 85ccb0b..c337820 (7 files). I read the code, not the PR body. I did not build or run anything. I did not run the fleet jobs again. Findings
Checked and found fine
|
…s socket's reply budget; a newcomer always fits (red, review of c337820) Review FIX on #28408 (landing/reviews/c3378208dbab...md): evicting the biggest connection whose send buffer is full can end a client that reads (no POLLOUT only means a few KiB are unread), can evict without freeing enough, and misses a writer's in-flight reply. The principled design it names: a share of the socket's budget per connection, no stall signal, no eviction. a_connection_takes_at_most_half_of_what_is_left_and_a_newcomer_always_fits is red on this commit: RenderService::for_connection / OutboundByteBudget:: share_of are seams that forward to the pool with no share rule yet. The stalled-client test's SessionClient reads with no deadline (R5).
…socket's budget; no stall eviction (review of c337820, #27698 follow-up) Replaces the eviction in c337820, which the review found wrong: no POLLOUT only means a few KiB are unread, so a client that reads could be ended; an eviction did not always free enough; a writer's in-flight reply was not counted; and the watched descriptor made five per connection against the budget's four (R-L1). Now each line and WebSocket connection gets its own share of its socket's outbound and control budgets (RenderService::for_connection, OutboundByteBudget::share_of). Its first bytes get in whenever the pool has room; more only while it then holds no more than the pool still has free. A client that stops reading stops at about half of what is left, and a newcomer's first reply always fits, so no client starves another: the same rule as the connection admission's fair share. A share counts every byte its connection holds, a reply being written included, and gives it back to the pool when dropped. Only bytes decide it: no stall signal, no timer, no eviction, no extra descriptor. A single connection now holds at most about half of its socket's pool (64 of 128 MiB per budget) instead of all of it; past that, its reply fails and the connection closes, as at a full pool before. Per-reply limits (32 MiB) are unchanged.
|
Conflict wave: I did not merge feat-cmux-next into this branch. This PR is stacked on an earlier head of #28343 ( Question for the author: should the per-connection outbound share ( |
Stacked on #28343 (base
fix/pa-b1). Follow-up to its second review, note 1 (#28343 (comment)).Summary
#28343 removes the 2 s write deadline, so a client that reads late keeps its connection. The cost: a session-socket client that stops reading (a frontend stopped with Ctrl-Z, a stuck agent) keeps its queued replies' bytes from the session socket's shared outbound budget until it reads or hangs up. When that budget is full, other terminal clients' replies can't be made, and their connections close. The app isn't affected, because it has its own budget since #28343.
Now each connection draws its queued replies from its own share of its socket's budget (
RenderService::for_connection,OutboundByteBudget::share_of), on the local socket and the WebSocket listener.The first version (
c3378208dbab) evicted the biggest connection whose send buffer was full. Its review (#28408 (comment)) found that this could end a client that reads: noPOLLOUTonly means a few KiB are unread. It also found that an eviction did not always free enough, that a writer's in-flight reply was not counted, and that the extra descriptor broke the fd budget (R-L1).14f202d54a50replaces it with the share above.R9 rows: the session socket's shared outbound budget (R-L2: a share for each connection) and R-L5 (no deadline decides anything; only bytes).
Owner: PL-daemon owns
cmux-tui-core(OWNERS.md).Testing
Red first, then the fix:
a8d1aa6a97e5(behavioral red),c3378208dbab(first fix, replaced),02616aae6106(unit red for the share rule, with a seam that forwards to the pool),14f202d54a50(the share).a_stalled_client_never_starves_the_other_clients_of_the_shared_budget(server/tests/connection_lanes.rs): a 64 KiB session-socket budget (test-only constructor). One client asks for far more and never reads; after its connection ends (the daemon'sClientDetachedevent, no timer), a new client and an idle client that reads must both get replies. No read deadline.a_connection_takes_at_most_half_of_what_is_left_and_a_newcomer_always_fits: on a 100-byte pool, one connection takes at most 50 bytes, two newcomers' first replies fit, the control budget shares the same way, and what a connection gives back returns to the pool.a8d1aa6a97e5connection_lanes.rs:556"a stalled client starved the next client: null"02616aae610614f202d54a50, Linux (connection_lanes)Observed / Not run
No capture: daemon change with no UI.
Performance
No perf numbers: this is about how outbound memory is shared between clients, not about speed.
Changelog
Fixed: A terminal client that stops reading no longer stops other terminal clients from getting replies from the daemon.