ee157a2c
feat: the daemon holds 32 clients, one per wall tile
a73x 2026-09-03 11:43
Commit message
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 |