a73x

29cbd304

fix: a QUIC client sends CONNECTION_CLOSE on teardown, so a poll frees its slot at once

a73x   2026-09-03 05:20

Commit message
fix: a QUIC client sends CONNECTION_CLOSE on teardown, so a poll frees its slot at once

Every QUIC connection takes a daemon client slot at the handshake, and
the wall polls each QUIC host once a second on a connection of its own.
quic.Client.deinit deleted the connection and closed the socket without
a goodbye, so the daemon freed each poll's slot only at its 15 s idle
timer: eight polls filled the eight slots, and a laptop whose only
clients were another wall's polls refused its own wall (clients=8 with
one client per session, found 2026-09-02).

sayGoodbye writes CONNECTION_CLOSE before the socket closes, mirroring
the listener's closeAll; the daemon's read path already kills a peer
that closed. Pinned by a listener test (slot freed within 1 s at a 5 s
idle) and a handoff-group leg that samples the daemon's clients= gauge
while a hub polls it (109 scenarios now).

CLAUDE.md
Old New
@@ -187,6 +187,14 @@ own. Test fixtures in `test/`:
187 tile locally, because a pump parked in `dial` reads no ask, a pending pane 187 tile locally, because a pump parked in `dial` reads no ask, a pending pane
188 has no pump at all, and birthing onto an `unreachable` row is a designed 188 has no pump at all, and birthing onto an `unreachable` row is a designed
189 path. 189 path.
190 - **A QUIC client says goodbye.** Every QUIC connection takes one of the
191 daemon's eight client slots at the handshake, attached or not, and the
192 wall polls each QUIC host once a second on a connection of its own.
193 `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes
194 the socket; a teardown that only dropped the socket left the daemon to
195 learn from its 15 s idle timer, and eight polls filled the eight slots
196 in eight seconds — every real attach after that was refused, on a
197 laptop whose only clients were another wall's polls (2026-09-02).
190 - **A pid-named leftover is reaped by its successor, never by a signal 198 - **A pid-named leftover is reaped by its successor, never by a signal
191 handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*` 199 handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*`
192 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner 200 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner
docs/decisions.md
Old New
@@ -7834,3 +7834,23 @@ spawned and waited for — read its pid BEFORE the wait, `Child.wait` sets
7834 `id` to undefined, which cost one red round) and by the boot group's 7834 `id` to undefined, which cost one red round) and by the boot group's
7835 restart leg, which now asserts the SIGKILLed daemon's agent directory is 7835 restart leg, which now asserts the SIGKILLed daemon's agent directory is
7836 there before the restart and gone after it. 7836 there before the restart and gone after it.
7837
7838 **A laptop that refused its own wall.** Reported the same afternoon: `mux`
7839 on the laptop read `refused` while a wall over ssh was fine. `mux d stats`
7840 there said `clients=8 attaches=6 sessions=2`, with one client on each
7841 session — six slots held by connections attached to nothing. From this
7842 box, `ss -uanp` showed the wall's two tile connections to the laptop plus a
7843 third that changed port every second: the session poll, which opens a QUIC
7844 connection per tick. `quic.Client.deinit` deleted the connection and closed
7845 the socket without CONNECTION_CLOSE, so the daemon learned of each poll's
7846 end from its 15 s idle timer; at one poll a second the eight slots were
7847 gone in eight seconds, and a hub polling a scratch daemon reproduced the
7848 climb 2, 4, 6, 8 in as many seconds. The fix is `sayGoodbye` on the
7849 client's teardown, the mirror of the listener's `closeAll`. The daemon
7850 needed nothing: its read path already kills a connection whose peer
7851 closed. Pinned by a listener test (slot freed within 1 s at a 5 s idle)
7852 and a handoff-group leg sampling the daemon's gauge while a hub polls —
7853 whose first draft passed against the bug, because `mux d stats` puts
7854 `session 0 clients=1` on the SAME line and a greedy match read that
7855 instead of `clients=N attaches=`. The mutant's series, 1 2 3 4 5 6 7 8,
7856 is what the leg now prints when it fails.
src/quic.zig
Old New
@@ -1061,6 +1061,7 @@ pub const Client = struct {
1061 } 1061 }
1062 1062
1063 pub fn deinit(self: *Client) void { 1063 pub fn deinit(self: *Client) void {
1064 self.sayGoodbye();
1064 self.in.deinit(self.alloc); 1065 self.in.deinit(self.alloc);
1065 // ngtcp2 before the egress ring: it holds vectors into that ring for 1066 // ngtcp2 before the egress ring: it holds vectors into that ring for
1066 // anything unacknowledged, so freeing the ring first leaves it 1067 // anything unacknowledged, so freeing the ring first leaves it
@@ -1074,6 +1075,36 @@ pub const Client = struct {
1074 self.alloc.destroy(self); 1075 self.alloc.destroy(self);
1075 } 1076 }
1076 1077
1078 /// CONNECTION_CLOSE on the way out, so the daemon frees this
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
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,
1083 /// and every real attach after that was refused — found on a laptop
1084 /// whose only clients were another wall's polls (2026-09-02). Best
1085 /// effort: a peer that never handshook or already closed gets nothing.
1086 fn sayGoodbye(self: *Client) void {
1087 const conn = self.conn orelse return;
1088 if (self.dead) return;
1089 var ccerr: c.ngtcp2_ccerr = undefined;
1090 c.ngtcp2_ccerr_default(&ccerr);
1091 var buf: [max_udp]u8 = undefined;
1092 var ps: c.ngtcp2_path_storage = undefined;
1093 c.ngtcp2_path_storage_zero(&ps);
1094 var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 };
1095 const n = c.ngtcp2_conn_write_connection_close_versioned(
1096 conn,
1097 &ps.path,
1098 c.NGTCP2_PKT_INFO_VERSION,
1099 &pi,
1100 &buf,
1101 buf.len,
1102 &ccerr,
1103 timestampNs(),
1104 );
1105 if (n > 0) _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch {};
1106 }
1107
1077 /// Readable means a datagram arrived, not that a frame is waiting. 1108 /// Readable means a datagram arrived, not that a frame is waiting.
1078 pub fn pollFd(self: *const Client) std.posix.fd_t { 1109 pub fn pollFd(self: *const Client) std.posix.fd_t {
1079 return self.fd; 1110 return self.fd;
src/server/quic_server.zig
Old New
@@ -1692,3 +1692,41 @@ test "closeAll: a connected client hears the goodbye" {
1692 test { 1692 test {
1693 std.testing.refAllDecls(@This()); 1693 std.testing.refAllDecls(@This());
1694 } 1694 }
1695
1696 test "Listener: a peer's deinit frees its slot at once, not at the idle timeout" {
1697 const alloc = std.testing.allocator;
1698 const key: quic.Key = .{ .bytes = [_]u8{0x3D} ** quic.key_len };
1699 // Long enough that an idle-timer reap inside this test's budget is
1700 // impossible: the close observed below has to be the peer's goodbye.
1701 const idle_ms = 5000;
1702 var owner: EchoOwner = .{};
1703 defer owner.deinit();
1704 const setup = try loopbackListener(alloc, key, &owner, idle_ms);
1705 defer setup.l.deinit();
1706
1707 var cl = try TestPeer.init(setup.addr, key);
1708 try std.testing.expect(pump(setup.l, &cl, 5000, struct {
1709 fn f(o: *EchoOwner, t: *TestPeer) bool {
1710 return t.cl.handshake_done and o.opened > 0;
1711 }
1712 }.f, &owner));
1713 try std.testing.expectEqual(@as(usize, 0), owner.closed);
1714
1715 // The peer leaves the way every wall poll does: deinit, nothing else.
1716 // Without CONNECTION_CLOSE the listener would hold this slot for
1717 // `idle_ms`, and a daemon's eight slots are eight seconds of polling.
1718 cl.deinit();
1719 var waited: u64 = 0;
1720 while (waited < 1000 and owner.closed == 0) : (waited += 10) {
1721 var fds = [_]std.posix.pollfd{
1722 .{ .fd = setup.l.fd, .events = std.posix.POLL.IN, .revents = 0 },
1723 };
1724 _ = std.posix.poll(&fds, 10) catch break;
1725 if (fds[0].revents != 0) setup.l.readable();
1726 setup.l.tick();
1727 for (setup.l.conns) |slot| {
1728 if (slot) |cn| setup.l.drain(cn);
1729 }
1730 }
1731 try std.testing.expectEqual(@as(usize, 1), owner.closed);
1732 }
test/e2e.sh
Old New
@@ -170,8 +170,8 @@ done
170 # one of those and adds a convergence point would be pinning a fact every 170 # one of those and adds a convergence point would be pinning a fact every
171 # leg above already establishes. 171 # leg above already establishes.
172 172
173 [ "$OK_COUNT" = "108" ] || { 173 [ "$OK_COUNT" = "109" ] || {
174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 108 —" 174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 109 —"
175 echo " a scenario was added (update the pin) or silently lost" 175 echo " a scenario was added (update the pin) or silently lost"
176 exit 1 176 exit 1
177 } 177 }
test/e2e_04_handoff.sh
Old New
@@ -355,6 +355,43 @@ chmod 600 "$HKEY"
355 start_daemon "$SOCK17" "$OUT.d17.d" "key-mismatch daemon never bound" --shell /bin/sh \ 355 start_daemon "$SOCK17" "$OUT.d17.d" "key-mismatch daemon never bound" --shell /bin/sh \
356 --quic "127.0.0.1:$HQPORT" --key "$HKEY" 356 --quic "127.0.0.1:$HQPORT" --key "$HKEY"
357 HDPID=$DPID 357 HDPID=$DPID
358
359 # ---- a QUIC poller holds one client slot, not one per second -----------
360 #
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
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
365 # slots, and every attach after that was refused — found on a laptop whose
366 # only clients were another wall's polls (2026-09-02). Sampled off
367 # `mux d stats`, which counts slots HELD, while a hub polls this daemon.
368 HPOLLSTATE="${TMPDIR:-/tmp}/mux-e2e-hpoll-$$"
369 defer_rm "$HPOLLSTATE"
370 mkdir -p "$HPOLLSTATE"
371 XDG_STATE_HOME="$HPOLLSTATE" "$MUX" web "quic://127.0.0.1:$HQPORT" --key "$HKEY" \
372 --port "$((HQPORT + 1))" > "$OUT.hpoll" 2>&1 &
373 HPOLLPID=$!
374 defer_kill "$HPOLLPID"
375 # The daemon's gauge is the `clients=N attaches=` pair; the same line goes
376 # on to say `session 0 clients=1`, which a greedy match reads instead (the
377 # first draft of this leg did, and passed against the bug).
378 HPOLLMAX=0
379 HPOLLSEEN=""
380 for _ in 1 2 3 4 5 6 7 8; do
381 sleep 1
382 HPOLLNOW=$("$MUX" d stats --sock "$SOCK17" 2>/dev/null | sed -n 's/.* clients=\([0-9]*\) attaches=.*/\1/p' | head -1)
383 HPOLLSEEN="$HPOLLSEEN ${HPOLLNOW:-?}"
384 [ "${HPOLLNOW:-0}" -gt "$HPOLLMAX" ] && HPOLLMAX=$HPOLLNOW
385 done
386 softkill "$HPOLLPID"
387 # 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
389 # eighth second.
390 [ "$HPOLLMAX" -le 3 ] || {
391 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; }
393 ok "a QUIC poller holds one client slot, not one per second"
394
358 HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") 395 HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG")
359 HT0=$(date +%s%N) 396 HT0=$(date +%s%N)
360 pipe_mux "$OUT.h4" "$OUT.h4.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ 397 pipe_mux "$OUT.h4" "$OUT.h4.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \