a73x

ee157a2c

feat: the daemon holds 32 clients, one per wall tile

a73x   2026-09-03 11:43

Commit message
feat: the daemon holds 32 clients, one per wall tile

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

CLAUDE.md
Old New
@@ -247,13 +247,24 @@ own. Test fixtures in `test/`:
247 count refused a daemon that works. An ended session's pane leaves on the 247 count refused a daemon that works. An ended session's pane leaves on the
248 next list, not on the keypress. 248 next list, not on the keypress.
249 - **A QUIC client says goodbye.** Every QUIC connection takes one of the 249 - **A QUIC client says goodbye.** Every QUIC connection takes one of the
250 daemon's eight client slots at the handshake, attached or not, and the 250 daemon's `max_clients` slots at the handshake, attached or not, and the
251 wall polls each QUIC host once a second on a connection of its own. 251 wall polls each QUIC host once a second on a connection of its own.
252 `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes 252 `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes
253 the socket; a teardown that only dropped the socket left the daemon to 253 the socket; a teardown that only dropped the socket left the daemon to
254 learn from its 15 s idle timer, and eight polls filled the eight slots 254 learn from its 15 s idle timer, so the polls filled the table at a slot
255 in eight seconds — every real attach after that was refused, on a 255 a second and every real attach after that was refused — on a laptop
256 laptop whose only clients were another wall's polls (2026-09-02). 256 whose only clients were another wall's polls, when the daemon had eight
257 slots and so took eight seconds (2026-09-02). The table is 32 deep now,
258 which buys time and fixes nothing: the goodbye is what bounds it.
259 A slot is spent per ATTACH, not per session, so 32 matches `max_sessions`
260 and `wallview.max_tiles` — one full wall of tiles fits exactly, and a
261 second wall on the same daemon is refused. The listener's `max_conns`
262 stays ABOVE `max_clients` (a connection exists from the handshake and only
263 then asks for a slot, and a peer that finds no connection is dropped
264 silently rather than refused), pinned by a test because a transport file
265 does not read the daemon's tables. And the listener OUTLIVES the slots
266 that close through it: a QUIC sink closes its connection via the listener,
267 so a borrowed listener's `deinit` is registered AFTER the server's.
257 - **A pid-named leftover is reaped by its successor, never by a signal 268 - **A pid-named leftover is reaped by its successor, never by a signal
258 handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*` 269 handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*`
259 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner 270 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner
docs/decisions.md
Old New
@@ -8128,3 +8128,62 @@ status` says the stripe is 11 rows so the arithmetic is the layout's, not
8128 the comment's. Graded by mutation: with the row translation capped at one 8128 the comment's. Graded by mutation: with the row translation capped at one
8129 row every older leg passes and only the stacked leg fails. The suite's 8129 row every older leg passes and only the stacked leg fails. The suite's
8130 scenario pin goes from 111 to 112. 8130 scenario pin goes from 111 to 112.
8131
8132 ## 2026-09-03 — the daemon holds 32 clients, and the listener outlives them
8133
8134 **The raise.** `max_clients` 8 -> 32. A slot is spent per ATTACH, not per
8135 session: every tile on a wall is its own attach, and each QUIC host's
8136 once-a-second poll holds one more connection. Seven or eight tiles on one box
8137 therefore refused the next attach, which is ordinary use, not an edge. 32
8138 matches `max_sessions` and `wallview.max_tiles`, so exactly one full wall
8139 fits — and nothing else does at the same time, which is the accepted ceiling
8140 rather than an oversight.
8141
8142 **A use-after-free the raise exposed, in the tests only.** `max_conns` (the
8143 QUIC listener's connection table) was documented as headroom over
8144 `max_clients`, so raising the client table meant raising it too. At 40 the
8145 test suite segfaulted in `Listener.closeConn`, reached from `Server.deinit`
8146 -> `teardownClient` -> `Sink.close`. Three tests in `server_test_quic.zig`
8147 registered `defer td.deinit()` BEFORE `defer q.l.deinit()`; defers run in
8148 reverse, so the borrowed listener was freed first and the server's teardown
8149 then closed its QUIC client slots through freed memory.
8150
8151 Production was never wrong: `main.zig` registers the listener's defer at ~560
8152 and the server's at ~600, so the server tears down first, and `Server.deinit`
8153 already states the rule for the lazily-bound arm ("the listener has to outlive
8154 the slots that hold it"). Only the tests had it backwards.
8155
8156 Size did not cause the bug, it only decided whether it was visible: at
8157 `max_conns = 16` the freed listener's memory happened to stay readable and
8158 the read returned garbage quietly; at 40 it faulted. The bug was live at both.
8159 Measured by reverting the test fix: (8, 16) green, (32, 16) green, (32, 40)
8160 segfault. With the defer order corrected, (32, 40) is green over two runs and
8161 (32, 16) fails only on the new capacity assertion.
8162
8163 **Why `max_conns` was not simply left at 16.** Holding it level with or below
8164 `max_clients` has a user-visible cost that documenting would not have fixed:
8165 a peer that finds no free CONNECTION is dropped SILENTLY — `acceptConn` spells
8166 it "full: drop, the peer will retry" — rather than answered and closed. Peers
8167 17 through 32 would hang on retries while the daemon still had seats, and a
8168 host polled over QUIC would read `unreachable` though it was running fine. So
8169 the test order is fixed and `max_conns` is 40.
8170
8171 Restated in `quic_server.zig` rather than derived from `server.zig`, because a
8172 transport does not read the daemon's tables; `server_test_quic` asserts
8173 `max_conns > max_clients` so the restatement cannot go stale unnoticed.
8174
8175 **Filling a 32-slot table in the harness.** The refused-tile leg used to fill
8176 the client table out of sessions, which worked only while 8 slots ran out
8177 before 32 names. At 32 both tables saturate together and the picker's `c`
8178 would be refused for want of a NAME — a different refusal reaching a different
8179 tile state. The leg now keeps its eight-pane wall and adds 24 long-lived
8180 holders over those same eight names: 24 + 8 = 32 slots with the session table
8181 still at 8 of 32.
8182
8183 The holders taught one thing worth writing down: closing their shared fifo
8184 does NOT release them. A wall treats stdin EOF as "the script that was typing
8185 has gone" (`wallview.zig` sets `stdin_open = false` and continues) rather than
8186 as a goodbye, which is why `fill_sessions` types the detach chord instead of
8187 just closing. One shared fifo cannot carry a chord per holder, so
8188 `release_holds` signals the pids and then waits on `mux d stats` for the slots
8189 to come back — the daemon's gauge is the witness, never the kill.
src/quic.zig
Old New
@@ -1079,9 +1079,12 @@ pub const Client = struct {
1079 /// connection's client slot NOW rather than when its idle timer expires 1079 /// connection's client slot NOW rather than when its idle timer expires
1080 /// (15 s by default). The wall polls every QUIC host once a second on a 1080 /// (15 s by default). The wall polls every QUIC host once a second on a
1081 /// connection of its own; a teardown that just dropped the socket left 1081 /// connection of its own; a teardown that just dropped the socket left
1082 /// eight of those holding a daemon's eight slots inside eight seconds, 1082 /// those polls holding one client slot each, so the table filled at a
1083 /// and every real attach after that was refused — found on a laptop 1083 /// slot a second and every real attach after that was refused — found
1084 /// whose only clients were another wall's polls (2026-09-02). Best 1084 /// on a laptop whose only clients were another wall's polls, when the
1085 /// daemon had eight slots and so took eight seconds (2026-09-02). The
1086 /// table is `max_clients` deep now, which buys time and fixes nothing:
1087 /// without the goodbye a long-lived wall still fills it. Best
1085 /// effort: a peer that never handshook or already closed gets nothing. 1088 /// effort: a peer that never handshook or already closed gets nothing.
1086 fn sayGoodbye(self: *Client) void { 1089 fn sayGoodbye(self: *Client) void {
1087 const conn = self.conn orelse return; 1090 const conn = self.conn orelse return;
src/server/quic_server.zig
Old New
@@ -37,11 +37,23 @@ pub const Handler = struct {
37 onClose: *const fn (ctx: *anyopaque, id: u64) void, 37 onClose: *const fn (ctx: *anyopaque, id: u64) void,
38 }; 38 };
39 39
40 /// Connections the listener will hold at once. Larger than the daemon's 40 /// Connections the listener will hold at once, and deliberately MORE than
41 /// `max_clients` on purpose: a connection exists from the moment its handshake 41 /// the daemon's `max_clients`. A connection exists from the moment its
42 /// completes and only then asks for a slot, so the room is for handshakes in 42 /// handshake completes and only then asks for a client slot, so the surplus
43 /// flight. One that finds no slot is answered and closed. 43 /// is room for handshakes in flight and for peers about to be refused a slot.
44 const max_conns = 16; 44 /// One that finds no slot is answered and closed, which is a peer that knows.
45 ///
46 /// Running level with the client table, or under it, is the failure to avoid:
47 /// a peer that finds no CONNECTION is dropped SILENTLY — `acceptConn` spells
48 /// it "full: drop, the peer will retry" — so it hangs on retries while the
49 /// daemon still has seats, and a host polled over QUIC reads `unreachable`
50 /// though it is running fine.
51 ///
52 /// Restated here rather than derived: this file is a transport, and a
53 /// transport does not read the daemon's tables. The restatement is pinned
54 /// instead — `server_test_quic` asserts `max_conns > max_clients`, so raising
55 /// one without the other fails the build rather than going quiet.
56 pub const max_conns = 40;
45 57
46 /// Source Connection IDs cached per connection. ngtcp2 caps its own pool at 58 /// Source Connection IDs cached per connection. ngtcp2 caps its own pool at
47 /// NGTCP2_MAX_SCID_POOL_SIZE (8), so this is headroom; and because the 59 /// NGTCP2_MAX_SCID_POOL_SIZE (8), so this is headroom; and because the
@@ -1714,7 +1726,8 @@ test "Listener: a peer's deinit frees its slot at once, not at the idle timeout"
1714 1726
1715 // The peer leaves the way every wall poll does: deinit, nothing else. 1727 // The peer leaves the way every wall poll does: deinit, nothing else.
1716 // Without CONNECTION_CLOSE the listener would hold this slot for 1728 // Without CONNECTION_CLOSE the listener would hold this slot for
1717 // `idle_ms`, and a daemon's eight slots are eight seconds of polling. 1729 // `idle_ms`, so a poll a second would spend the daemon's whole client
1730 // table in `max_clients` seconds and refuse every attach after that.
1718 cl.deinit(); 1731 cl.deinit();
1719 var waited: u64 = 0; 1732 var waited: u64 = 0;
1720 while (waited < 1000 and owner.closed == 0) : (waited += 10) { 1733 while (waited < 1000 and owner.closed == 0) : (waited += 10) {
src/server/server.zig
Old New
@@ -32,7 +32,14 @@ const AgentChan = agent_mod.AgentChan;
32 const AgentCloseKind = agent_mod.AgentCloseKind; 32 const AgentCloseKind = agent_mod.AgentCloseKind;
33 pub const max_agent_chans = agent_mod.max_agent_chans; 33 pub const max_agent_chans = agent_mod.max_agent_chans;
34 34
35 pub const max_clients = 8; 35 /// One slot per ATTACH, not per session: every tile on a wall is its own
36 /// attach, and two clients watching one session spend two slots. At 8 an
37 /// ordinary wall refused its own next tile, because seven tiles plus a
38 /// QUIC host's once-a-second poll already held every slot. Matching
39 /// `max_sessions` and `wallview.max_tiles` means one full wall of 32 tiles
40 /// fits exactly — and leaves nothing over, so a SECOND wall on the same
41 /// daemon is still refused, which is the intended ceiling and not a bug.
42 pub const max_clients = 32;
36 pub const max_observers = 4; 43 pub const max_observers = 4;
37 44
38 /// A connection that has not attached yet: see `Server.observers`. 45 /// A connection that has not attached yet: see `Server.observers`.
@@ -253,9 +260,12 @@ const AwaitState = struct {
253 saw_busy: bool = false, 260 saw_busy: bool = false,
254 }; 261 };
255 262
256 // Matches `wallview.max_tiles`. 32 NAMEABLE sessions to cycle through, not 263 // Matches `wallview.max_tiles`, and now `max_clients` too: 32 nameable
257 // 32 watched at once: every tile is its own client slot, and `max_clients` 264 // sessions, and enough client slots that one wall can watch every one of
258 // still refuses the ninth. 265 // them at once. The two are still different questions — a slot is spent
266 // per attach, so a session two clients hold costs one name and two slots,
267 // and the client table is what refuses first whenever anything beyond a
268 // single full wall dials in.
259 pub const max_sessions = 32; 269 pub const max_sessions = 32;
260 270
261 /// The smallest grid a session may exist at, read by `resolveSession` and 271 /// The smallest grid a session may exist at, read by `resolveSession` and
src/server/server_test_agent.zig
Old New
@@ -648,10 +648,16 @@ test "Server: a client's agent channels die with the client" {
648 test "Server: a QUIC client's agent channels die with the client" { 648 test "Server: a QUIC client's agent channels die with the client" {
649 const alloc = std.testing.allocator; 649 const alloc = std.testing.allocator;
650 var td = try h.TestDaemon.init(alloc, "qagentgone", .{ .shell = "/bin/sh" }); 650 var td = try h.TestDaemon.init(alloc, "qagentgone", .{ .shell = "/bin/sh" });
651 defer td.deinit();
652 const key: quic.Key = .{ .bytes = [_]u8{0x7E} ** quic.key_len }; 651 const key: quic.Key = .{ .bytes = [_]u8{0x7E} ** quic.key_len };
653 const q = try quicTestServer(&td.srv, key); 652 const q = try quicTestServer(&td.srv, key);
653 // The listener outlives the server: a QUIC sink closes its connection
654 // THROUGH the listener, so `Server.deinit` walking its client table needs
655 // it alive. Defers run in reverse, so the daemon's goes second. The
656 // success path at the end clears this client by hand and so would not
657 // notice the other order; every early return before it would, with a
658 // live slot still in the table.
654 defer q.l.deinit(); 659 defer q.l.deinit();
660 defer td.deinit();
655 661
656 var cl = try quic_server.TestPeer.init(q.addr, key); 662 var cl = try quic_server.TestPeer.init(q.addr, key);
657 defer cl.deinit(); 663 defer cl.deinit();
src/server/server_test_attach.zig
Old New
@@ -127,7 +127,7 @@ test "Server: serves scrollback chunks on request" {
127 try std.testing.expect(std.mem.indexOf(u8, chunk.?[6..], "seq 1 100") != null); 127 try std.testing.expect(std.mem.indexOf(u8, chunk.?[6..], "seq 1 100") != null);
128 } 128 }
129 129
130 test "Server: a full session refuses the next attach instead of displacing anyone" { 130 test "Server: a full client table refuses the next attach instead of displacing anyone" {
131 const alloc = std.testing.allocator; 131 const alloc = std.testing.allocator;
132 132
133 var td = try h.TestDaemon.init(alloc, "full", .{ .shell = "/bin/sh" }); 133 var td = try h.TestDaemon.init(alloc, "full", .{ .shell = "/bin/sh" });
@@ -148,7 +148,8 @@ test "Server: a full session refuses the next attach instead of displacing anyon
148 try std.testing.expect(first != null); 148 try std.testing.expect(first != null);
149 } 149 }
150 150
151 // The ninth attach is refused; nobody already attached is evicted. 151 // The attach past `max_clients` is refused; nobody already attached
152 // is evicted.
152 const extra = try dial.dialAttach(td.sock_path, 80, 24); 153 const extra = try dial.dialAttach(td.sock_path, 80, 24);
153 defer extra.close(); 154 defer extra.close();
154 var refused = false; 155 var refused = false;
src/server/server_test_quic.zig
Old New
@@ -19,6 +19,24 @@ fn attachOver(cl: *quic_server.TestPeer, buf: *std.ArrayList(u8), alloc: std.mem
19 cl.drain(); 19 cl.drain();
20 } 20 }
21 21
22 test "the QUIC listener holds more connections than the daemon has client slots" {
23 // A QUIC connection exists from the moment its handshake completes and
24 // only THEN asks for a client slot, so the listener needs room the client
25 // table does not: peers mid-handshake, and peers whose slot request is
26 // about to be refused, all occupy a connection first.
27 //
28 // A listener no deeper than the client table drops peers the daemon still
29 // has seats for, and that drop is SILENT — `acceptConn` answers a full
30 // table with "full: drop, the peer will retry", so those peers hang on
31 // retries instead of being told anything, and a host polled that way
32 // reads `unreachable` on a daemon that is running fine.
33 //
34 // Pinned rather than derived: `quic_server.zig` is a transport and does
35 // not read the daemon's tables, so its number is restated there. This is
36 // what catches the restatement going stale.
37 try std.testing.expect(quic_server.max_conns > srv_mod.max_clients);
38 }
39
22 test "refusalFrame: the exact refusal wire bytes" { 40 test "refusalFrame: the exact refusal wire bytes" {
23 // The literal pins what this daemon's QUIC path puts on the wire, and 41 // The literal pins what this daemon's QUIC path puts on the wire, and
24 // nothing about the client that reads it. Spelled out rather than 42 // nothing about the client that reads it. Spelled out rather than
@@ -92,14 +110,27 @@ test "drainPending: the poll slice is floored at 1ms and capped by ngtcp2" {
92 test "Server: output reaches a silent QUIC client without waiting for it to speak" { 110 test "Server: output reaches a silent QUIC client without waiting for it to speak" {
93 const alloc = std.testing.allocator; 111 const alloc = std.testing.allocator;
94 var td = try h.TestDaemon.open(alloc, "qquiet"); 112 var td = try h.TestDaemon.open(alloc, "qquiet");
95 defer td.deinit();
96 113
97 // /bin/cat: a session that emits nothing on its own, so the only output 114 // /bin/cat: a session that emits nothing on its own, so the only output
98 // in this test is the output the test causes. 115 // in this test is the output the test causes.
99 try td.start(.{ .shell = "/bin/cat" });
100 const key: quic.Key = .{ .bytes = [_]u8{0x4D} ** quic.key_len }; 116 const key: quic.Key = .{ .bytes = [_]u8{0x4D} ** quic.key_len };
101 const q = try quicTestServer(&td.srv, key); 117 const q = setup: {
118 // Only until the pair below is registered: an error before that
119 // leaves nobody to free the daemon. Scoped to this block so it
120 // cannot fire alongside the `defer td.deinit()` that follows.
121 errdefer td.deinit();
122 try td.start(.{ .shell = "/bin/cat" });
123 break :setup try quicTestServer(&td.srv, key);
124 };
125 // ORDER IS LOAD-BEARING, and it is the order main.zig gives the real
126 // daemon: defers run in reverse, so the server tears down FIRST and the
127 // borrowed listener outlives it. A QUIC sink closes its connection
128 // THROUGH the listener (`Sink.close` -> `Listener.closeConn`), so
129 // `Server.deinit` walking its client table reads the listener after the
130 // other order would have freed it. `server.zig`'s deinit says the same
131 // rule for the lazily-bound arm.
102 defer q.l.deinit(); 132 defer q.l.deinit();
133 defer td.deinit();
103 134
104 var cl = try quic_server.TestPeer.init(q.addr, key); 135 var cl = try quic_server.TestPeer.init(q.addr, key);
105 defer cl.deinit(); 136 defer cl.deinit();
@@ -181,10 +212,11 @@ test "Server: output reaches a silent QUIC client without waiting for it to spea
181 test "Server: a QUIC client still receives the shell's exit status" { 212 test "Server: a QUIC client still receives the shell's exit status" {
182 const alloc = std.testing.allocator; 213 const alloc = std.testing.allocator;
183 var td = try h.TestDaemon.init(alloc, "qexit", .{ .shell = "/bin/sh" }); 214 var td = try h.TestDaemon.init(alloc, "qexit", .{ .shell = "/bin/sh" });
184 defer td.deinit();
185 const key: quic.Key = .{ .bytes = [_]u8{0x31} ** quic.key_len }; 215 const key: quic.Key = .{ .bytes = [_]u8{0x31} ** quic.key_len };
186 const q = try quicTestServer(&td.srv, key); 216 const q = try quicTestServer(&td.srv, key);
217 // The listener outlives the server: see the ordering note above.
187 defer q.l.deinit(); 218 defer q.l.deinit();
219 defer td.deinit();
188 220
189 var cl = try quic_server.TestPeer.init(q.addr, key); 221 var cl = try quic_server.TestPeer.init(q.addr, key);
190 defer cl.deinit(); 222 defer cl.deinit();
@@ -225,10 +257,11 @@ test "Server: a QUIC client still receives the shell's exit status" {
225 test "Server: one QUIC client leaving does not disturb the other" { 257 test "Server: one QUIC client leaving does not disturb the other" {
226 const alloc = std.testing.allocator; 258 const alloc = std.testing.allocator;
227 var td = try h.TestDaemon.init(alloc, "qtwo", .{ .shell = "/bin/sh" }); 259 var td = try h.TestDaemon.init(alloc, "qtwo", .{ .shell = "/bin/sh" });
228 defer td.deinit();
229 const key: quic.Key = .{ .bytes = [_]u8{0x77} ** quic.key_len }; 260 const key: quic.Key = .{ .bytes = [_]u8{0x77} ** quic.key_len };
230 const q = try quicTestServer(&td.srv, key); 261 const q = try quicTestServer(&td.srv, key);
262 // The listener outlives the server: see the ordering note above.
231 defer q.l.deinit(); 263 defer q.l.deinit();
264 defer td.deinit();
232 265
233 var a = try quic_server.TestPeer.init(q.addr, key); 266 var a = try quic_server.TestPeer.init(q.addr, key);
234 defer a.deinit(); 267 defer a.deinit();
src/server/server_test_session.zig
Old New
@@ -686,8 +686,10 @@ test "Server: an attach past max_sessions is refused with exit_status, sessions
686 // The default session holds slot 0, so `max_sessions - 1` more fill the 686 // The default session holds slot 0, so `max_sessions - 1` more fill the
687 // table. Each attach is confirmed before the next, so the refusal below is 687 // table. Each attach is confirmed before the next, so the refusal below is
688 // unambiguously "no session slot" — and each connection CLOSES, because 688 // unambiguously "no session slot" — and each connection CLOSES, because
689 // `max_clients` is smaller and would fill first, refusing for the wrong 689 // the two tables are the same size: holding all of them open would leave
690 // reason. The session outlives its client, which makes that safe. 690 // the client table one attach from full, and the refusal under test could
691 // then be either table's. The session outlives its client, which makes
692 // closing safe.
691 for (1..max_sessions) |i| { 693 for (1..max_sessions) |i| {
692 var nb: [8]u8 = undefined; 694 var nb: [8]u8 = undefined;
693 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i}); 695 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i});
test/e2e_04_handoff.sh
Old New
@@ -361,9 +361,12 @@ HDPID=$DPID
361 # Every QUIC connection takes a client slot at the handshake, and the wall 361 # Every QUIC connection takes a client slot at the handshake, and the wall
362 # polls each QUIC host once a second on a connection of its own. A client 362 # polls each QUIC host once a second on a connection of its own. A client
363 # that dropped its socket without CONNECTION_CLOSE left the daemon to 363 # that dropped its socket without CONNECTION_CLOSE left the daemon to
364 # learn of it from the 15 s idle timer: eight polls filled the eight 364 # learn of it from the 15 s idle timer, so the polls filled the table at a
365 # slots, and every attach after that was refused — found on a laptop whose 365 # slot a second and every attach after that was refused — found on a
366 # only clients were another wall's polls (2026-09-02). Sampled off 366 # laptop whose only clients were another wall's polls, when the daemon had
367 # eight slots and so took eight seconds (2026-09-02). The table is
368 # `max_clients` deep now, which only moves that deadline: the ceiling
369 # below is on slots held at ONCE and does not move with it. Sampled off
367 # `mux d stats`, which counts slots HELD, while a hub polls this daemon. 370 # `mux d stats`, which counts slots HELD, while a hub polls this daemon.
368 HPOLLSTATE="${TMPDIR:-/tmp}/mux-e2e-hpoll-$$" 371 HPOLLSTATE="${TMPDIR:-/tmp}/mux-e2e-hpoll-$$"
369 defer_rm "$HPOLLSTATE" 372 defer_rm "$HPOLLSTATE"
@@ -379,14 +382,14 @@ HPOLLMAX=0
379 HPOLLSEEN="" 382 HPOLLSEEN=""
380 for _ in 1 2 3 4 5 6 7 8; do 383 for _ in 1 2 3 4 5 6 7 8; do
381 sleep 1 384 sleep 1
382 HPOLLNOW=$("$MUX" d stats --sock "$SOCK17" 2>/dev/null | sed -n 's/.* clients=\([0-9]*\) attaches=.*/\1/p' | head -1) 385 HPOLLNOW=$(clients_now "$SOCK17")
383 HPOLLSEEN="$HPOLLSEEN ${HPOLLNOW:-?}" 386 HPOLLSEEN="$HPOLLSEEN ${HPOLLNOW:-?}"
384 [ "${HPOLLNOW:-0}" -gt "$HPOLLMAX" ] && HPOLLMAX=$HPOLLNOW 387 [ "${HPOLLNOW:-0}" -gt "$HPOLLMAX" ] && HPOLLMAX=$HPOLLNOW
385 done 388 done
386 softkill "$HPOLLPID" 389 softkill "$HPOLLPID"
387 # At most three: the hub's tile on session 0, the poll in flight, and the 390 # At most three: the hub's tile on session 0, the poll in flight, and the
388 # one before it still draining. Without the goodbye this reads 8 by the 391 # one before it still draining. Without the goodbye this reads 8 by the
389 # eighth second. 392 # eighth second — a slot a second, and the table's depth never enters it.
390 [ "$HPOLLMAX" -le 3 ] || { 393 [ "$HPOLLMAX" -le 3 ] || {
391 echo "e2e FAIL: eight seconds of polling held $HPOLLMAX client slots at once (want at most 3); per second:$HPOLLSEEN" 394 echo "e2e FAIL: eight seconds of polling held $HPOLLMAX client slots at once (want at most 3); per second:$HPOLLSEEN"
392 cat "$OUT.hpoll"; exit 1; } 395 cat "$OUT.hpoll"; exit 1; }
test/e2e_13_birth.sh
Old New
@@ -535,12 +535,22 @@ ok "a new tile takes the lowest free digit, and the daemon takes the name back"
535 # `labelText` — which pins the STRING, not that a refused pump ever 535 # `labelText` — which pins the STRING, not that a refused pump ever
536 # reaches it. 536 # reaches it.
537 # 537 #
538 # The refusal is the client TABLE, not the session table, and that is what 538 # The refusal is the client TABLE, not the session table, and the two are
539 # makes this cheap: every tile is its own attach and so its own client 539 # the same size now — `max_clients` and `max_sessions` are both 32 — so the
540 # slot, so a wall of eight panes on a terminal with room for ten stripes 540 # leg has to fill one of them without filling the other. A slot is spent per
541 # holds every slot the daemon has. The picker's `c` births a ninth and 541 # ATTACH, which is what makes that possible: eight sessions carry the
542 # dials it, the daemon refuses that attach before any replay frame, and the 542 # eight-pane wall, and twenty-four HOLDERS attached to those same eight
543 # pump paints `[refused]` on that tile's bar on its way out. 543 # names spend the remaining twenty-four slots. 24 + 8 = 32 and the client
544 # table is full, while the session table is still at eight of thirty-two —
545 # so the picker's `c` has a name it may create and no slot to attach it
546 # with. It dials, the daemon refuses that attach before any replay frame,
547 # and the pump paints `[refused]` on that tile's bar on its way out.
548 #
549 # Filling the table out of sessions alone is what the old shape did, back
550 # when eight slots and thirty-two names meant the client table ran out
551 # first. It cannot work at 32: thirty-two sessions would saturate both
552 # tables at once and `c` would be refused for want of a NAME, which is a
553 # different refusal reaching a different tile state.
544 # 554 #
545 # Two claims, and the second is the newer one: the wall STAYS. A refused 555 # Two claims, and the second is the newer one: the wall STAYS. A refused
546 # picker birth used to take mux down with it (rc 1 on a terminal); a tile 556 # picker birth used to take mux down with it (rc 1 on a terminal); a tile
@@ -553,16 +563,40 @@ RTSTATE="${TMPDIR:-/tmp}/mux-e2e-reftile-state-$$"
553 defer_rm "$RTSTATE" 563 defer_rm "$RTSTATE"
554 start_daemon "$SOCK75" "$OUT.rt.d" "refused-tile daemon never bound" --shell /bin/sh 564 start_daemon "$SOCK75" "$OUT.rt.d" "refused-tile daemon never bound" --shell /bin/sh
555 D75PID=$DPID 565 D75PID=$DPID
556 # Seven more, so the daemon holds eight sessions with its own `0`: exactly 566 # Seven more, so the daemon holds eight sessions with its own `0` — one per
557 # max_clients, so the wall fills the table and the ninth attach is the one 567 # pane of the wall below. The fills detach as they go and leave the slots
558 # under test. The fills detach as they go and leave the slots free. 568 # free, and the holders take them next.
559 fill_sessions "$SOCK75" "$OUT.rtfill" f 1 7 569 fill_sessions "$SOCK75" "$OUT.rtfill" f 1 7
560 wait_sessions "$SOCK75" 8 "refused-tile: the daemon should hold eight sessions" 570 wait_sessions "$SOCK75" 8 "refused-tile: the daemon should hold eight sessions"
571 # The holders count UP from an empty table, so the fills must have let go
572 # before the first one dials — otherwise a slot still draining shifts every
573 # target by one and the leg waits out its budget for a table that is
574 # already where it was asked to be.
575 wait_until 300 "refused-tile: the fill attaches never released their client slots" \
576 "[ \"\$(clients_now $SOCK75)\" = 0 ]" \
577 "\"\$MUX\" d stats --sock $SOCK75"
578 # Twenty-four holders over the eight names, three deep each. They attach
579 # BEFORE the wall so the wall's own eight tiles are the last eight slots in
580 # the table: latest-wins means the tiles set each session's size after the
581 # holders did, so no holder can resize a pane out from under the grid this
582 # leg reads.
583 hold_clients "$SOCK75" "$OUT.rthold" 24 "0 f1 f2 f3 f4 f5 f6 f7"
584 RTCL=$(clients_now "$SOCK75")
585 [ "$RTCL" = 24 ] || {
586 echo "e2e FAIL: refused-tile: the daemon holds $RTCL client slots, want 24"
587 echo " — the wall's eight tiles are what must fill the last eight"
588 "$MUX" d stats --sock "$SOCK75"; exit 1; }
589 # Bracket the wall: every tile attaches once, so the delta is the pane
590 # count and nothing else. It is the other half of the arithmetic above —
591 # 24 held plus 8 tiles is exactly `max_clients` — and it also says the
592 # refused birth seated NOBODY, because `seatClient` counts past every
593 # refusal and a refused attach never reaches it.
594 RTATT0=$(attaches_now "$SOCK75")
561 mkdir -p "$RTSTATE/mux" 595 mkdir -p "$RTSTATE/mux"
562 printf -- '--sock %s\n' "$SOCK75" > "$RTSTATE/mux/hosts" 596 printf -- '--sock %s\n' "$SOCK75" > "$RTSTATE/mux/hosts"
563 # Eight leaves, one per session: the wall is the file, so a saturated 597 # Eight leaves, one per session: the wall is the file, so the eight panes
564 # client table is eight panes somebody wrote down and not eight the poll 598 # that spend the last eight slots are eight somebody wrote down and not
565 # went and found. 599 # eight the poll went and found.
566 seed_layout "$RTSTATE" stacked \ 600 seed_layout "$RTSTATE" stacked \
567 "--sock $SOCK75#0" "--sock $SOCK75#f1" "--sock $SOCK75#f2" "--sock $SOCK75#f3" \ 601 "--sock $SOCK75#0" "--sock $SOCK75#f1" "--sock $SOCK75#f2" "--sock $SOCK75#f3" \
568 "--sock $SOCK75#f4" "--sock $SOCK75#f5" "--sock $SOCK75#f6" "--sock $SOCK75#f7" 602 "--sock $SOCK75#f4" "--sock $SOCK75#f5" "--sock $SOCK75#f6" "--sock $SOCK75#f7"
@@ -619,6 +653,18 @@ done
619 echo "e2e FAIL: refused-tile: the marker typed after the refusal reached no" 653 echo "e2e FAIL: refused-tile: the marker typed after the refusal reached no"
620 echo " session — the wall did not survive its own refused birth:" 654 echo " session — the wall did not survive its own refused birth:"
621 cat "$OUT.rtpc"; exit 1; } 655 cat "$OUT.rtpc"; exit 1; }
656 RTATT1=$(attaches_now "$SOCK75")
657 assert_attach_delta "$RTATT0" "$RTATT1" 8 "refused-tile"
658 release_holds
659 # Read the gauge back HERE too, not only inside the helper: the holders are
660 # the whole reason the table was full, so a release that quietly released
661 # nothing would leave every later reader of this leg believing an arithmetic
662 # that had stopped being true. The wall is gone by now, so nothing but the
663 # holders can be holding a slot.
664 RTCL_END=$(clients_now "$SOCK75")
665 [ "$RTCL_END" = 0 ] || {
666 echo "e2e FAIL: refused-tile: $RTCL_END client slots still held after release_holds"
667 "$MUX" d stats --sock "$SOCK75"; exit 1; }
622 assert_stopped "$SOCK75" "$D75PID" "refused-tile" "$OUT.rtstop" 668 assert_stopped "$SOCK75" "$D75PID" "refused-tile" "$OUT.rtstop"
623 D75PID="" 669 D75PID=""
624 ok "a birth the daemon refuses paints [refused] and leaves the wall standing" 670 ok "a birth the daemon refuses paints [refused] and leaves the wall standing"
test/e2e_lib.sh
Old New
@@ -477,6 +477,102 @@ fill_sessions() {
477 cat "$OUT.fill.$_fs_pfx.st"; exit 1; } 477 cat "$OUT.fill.$_fs_pfx.st"; exit 1; }
478 } 478 }
479 479
480 # hold_clients SOCK STATE COUNT NAMES — open COUNT attaches that STAY open,
481 # spread round-robin over NAMES (a space-separated list of sessions that
482 # already exist), so the daemon's CLIENT table fills without its session
483 # table growing. `fill_sessions` cannot do this job: its attaches detach as
484 # they go, which is what makes them cheap, and a slot is spent per ATTACH
485 # rather than per session — so filling a 32-slot table out of sessions alone
486 # would need all 32 names and leave the session table full too, and then a
487 # refusal under test could be either table's.
488 #
489 # ONE fifo for every holder, opened read-write HERE so it has a writer from
490 # this shell: each holder gets past `open()` and then blocks on a read that
491 # never comes. Per-holder fifos would need a spare fd each, which POSIX sh
492 # has no way to allocate.
493 #
494 # Closing that fd does NOT end them, and assuming it did is the trap this
495 # comment exists to close. A wall treats stdin EOF as "the script that was
496 # typing has gone", not as a goodbye: `wallview.zig` sets `stdin_open = false`
497 # and CONTINUES, because the session outlives its typist. That is the same
498 # reason `fill_sessions` types the detach chord rather than just closing —
499 # and one shared fifo cannot carry a chord per holder, since whichever holder
500 # read first would eat it. So `release_holds` signals them and then WATCHES
501 # the daemon's gauge come back down.
502 #
503 # THREE at a time, and for `fill_sessions`' reason: `acceptConn` parks every
504 # new connection in one of four OBSERVER slots and promotes it to a client
505 # only when its attach frame lands, so a wider batch contends for those four
506 # and the ones with nowhere to land are closed outright. Each batch is
507 # confirmed against the daemon's own gauge before the next dials, so a holder
508 # that never landed fails here with its own log rather than as a later
509 # refusal that proves nothing.
510 HOLD_FIFO=""
511 HOLD_PIDS=""
512 HOLD_SOCK=""
513 HOLD_BASE=""
514 hold_clients() {
515 _hc_sock="$1"; _hc_state="$2"; _hc_want="$3"; _hc_names="$4"
516 # fd 8 is the one fifo (fd 9 is pipe_mux's), so a second open would
517 # silently strand the first set still attached.
518 [ -z "$HOLD_FIFO" ] || {
519 echo "e2e FAIL: hold_clients: holders are already open on $HOLD_FIFO"
520 echo " — call release_holds before opening another set"
521 exit 1; }
522 HOLD_FIFO="$OUT.holdfifo"
523 HOLD_SOCK="$_hc_sock"
524 HOLD_PIDS=""
525 defer_rm "$HOLD_FIFO"
526 rm -f "$HOLD_FIFO"
527 mkfifo "$HOLD_FIFO"
528 exec 8<>"$HOLD_FIFO"
529 HOLD_BASE=$(clients_now "$_hc_sock")
530 [ -n "$HOLD_BASE" ] || {
531 echo "e2e FAIL: hold_clients: $_hc_sock gave no clients= reading to start from"
532 exit 1; }
533 _hc_i=0
534 while [ "$_hc_i" -lt "$_hc_want" ]; do
535 _hc_j=0
536 while [ "$_hc_j" -lt 3 ] && [ "$_hc_i" -lt "$_hc_want" ]; do
537 # Round-robin over the names: `set --` re-splits the list for
538 # every holder, then shifts to the one this index wants.
539 # shellcheck disable=SC2086
540 set -- $_hc_names
541 _hc_k=$(( _hc_i % $# ))
542 while [ "$_hc_k" -gt 0 ]; do shift; _hc_k=$((_hc_k - 1)); done
543 XDG_STATE_HOME="$_hc_state" "$MUX" --sock "$_hc_sock" --session "$1" \
544 < "$HOLD_FIFO" > "$OUT.hold.$_hc_i" 2>&1 &
545 HOLD_PIDS="$HOLD_PIDS $!"
546 defer_kill "$!"
547 _hc_i=$((_hc_i + 1)); _hc_j=$((_hc_j + 1))
548 done
549 _hc_target=$(( HOLD_BASE + _hc_i ))
550 wait_until 300 \
551 "hold_clients: $_hc_sock never reached clients=$_hc_target — a holder never attached" \
552 "[ \"\$(clients_now $_hc_sock)\" -ge $_hc_target ]" \
553 "\"\$MUX\" d stats --sock $_hc_sock; cat $OUT.hold.*"
554 done
555 }
556
557 # release_holds — send every holder away and WAIT for the daemon to say the
558 # slots came back. The wait is the point: closing the fifo alone leaves them
559 # all attached (see hold_clients above), and a leg that assumed otherwise
560 # would go on to measure a table that never emptied. Asking the daemon rather
561 # than trusting the signal is the same discipline as the rest of this file —
562 # the gauge is the witness, not the kill.
563 release_holds() {
564 [ -n "$HOLD_FIFO" ] || return 0
565 for _rh_p in $HOLD_PIDS; do
566 kill -TERM "$_rh_p" 2>/dev/null || true
567 done
568 exec 8>&-
569 wait_until 300 \
570 "release_holds: $HOLD_SOCK never fell back to clients=$HOLD_BASE — a holder kept its slot" \
571 "[ \"\$(clients_now $HOLD_SOCK)\" -le $HOLD_BASE ]" \
572 "\"\$MUX\" d stats --sock $HOLD_SOCK"
573 HOLD_FIFO=""; HOLD_PIDS=""
574 }
575
480 # repaints FILE — how many full repaints (ESC[2J) a capture holds. The 576 # repaints FILE — how many full repaints (ESC[2J) a capture holds. The
481 # first paint after a reconnect is always a full one (interact.zig 577 # first paint after a reconnect is always a full one (interact.zig
482 # `repaint_after_resync`), so a count that rose is the resume itself, seen 578 # `repaint_after_resync`), so a count that rose is the resume itself, seen
@@ -735,6 +831,16 @@ attaches_now() {
735 sed -n 's/.*[^_]attaches=\([0-9]*\).*/\1/p' 831 sed -n 's/.*[^_]attaches=\([0-9]*\).*/\1/p'
736 } 832 }
737 833
834 # clients_now SOCK — how many of the daemon's `max_clients` slots are HELD
835 # right now. The daemon-global gauge, not a session's: the same stats line
836 # goes on to say `session 0 clients=N`, so the match is anchored on the
837 # ` attaches=` that only ever follows the global one, and `head -1` refuses
838 # a second reading rather than running two together.
839 clients_now() {
840 timeout 5 "$MUX" d stats --sock "$1" 2>/dev/null |
841 sed -n 's/.* clients=\([0-9]*\) attaches=.*/\1/p' | head -1
842 }
843
738 # assert_attach_delta BEFORE AFTER WANT LABEL — how many attaches happened. 844 # assert_attach_delta BEFORE AFTER WANT LABEL — how many attaches happened.
739 # Empty readings fail loudly rather than arithmetically: `$(())` on an empty 845 # Empty readings fail loudly rather than arithmetically: `$(())` on an empty
740 # string is 0, and 0-0=0 would pass this check having measured nothing at 846 # string is 0, and 0-0=0 would pass this check having measured nothing at