a73x

ba62a7b2

test: pin the three QUIC fixes a mutation could still walk through

a73x   2026-08-08 17:42

Commit message
test: pin the three QUIC fixes a mutation could still walk through

The re-entrancy batch was correct and largely unguarded. Four pins, each
checked against the exact mutation that survived review.

reapClosing's drain: one connection whose owner answers and then closes,
both from inside one callback — the session-full refusal's exact shape.
Since send only queues, that drain is the reply's one chance to reach the
wire; deleting it now fails the test. The guarantee is bounded by the peer's
flow-control window at that instant, which a refusal always fits inside and
a large payload would not, and the test says so rather than implying more.

timeoutMs's negative cap: two lines on the back of the handshake test,
because the guard needs a LIVE connection with a finite expiry — the loop
skips connections without one and never reaches the cast that panics.

took-before-error: extracted as accountWrite, shared by the listener's drain
and the client's, which turns an ordering that needed fault injection to
observe into a property one unit test pins directly. TestClient's drain
keeps its own copy: it tracks a plain offset rather than an Egress ring, so
the helper does not fit it.

And the end-of-pump drainAll — the one line whose entire purpose is latency
was the one line no test defended. Pinning it took three attempts, and the
two failures are why the comment in it is long. Using the existing pump
helper failed to pin anything because that helper drains the CLIENT every
iteration, which makes it transmit: its acknowledgements alone hand feed() a
drain to carry the reply out on. Looping over several pumps failed for a
different reason — on loopback the PTO comes due within a few hundred
milliseconds and tick drains on expiry, so any budget long enough not to be
flaky is long enough to pass without the line existing. What pins it is
quiescing the connection until ngtcp2's next timer is comfortably in the
future, then a single pump with a client that only ever listens.

Also records what the asserts are worth: std.debug.assert is compiled out of
ReleaseFast, every binary this project builds today is Debug, and the day a
release build is added the tripwire goes with it. The invariant is
structural; the assert only makes a regression loud. The reasoned-not-pinned
list is down to two — closed-after-reap (constructible, a time trade, with
the construction written down) and the deinit orderings (whose honest
instrument is ASAN, now banked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/decisions.md
Old New
@@ -1078,13 +1078,32 @@ since `tick` only services connections whose timer is due) and the
1078 session-full refusal (queued, then the connection reaped before it could 1078 session-full refusal (queued, then the connection reaped before it could
1079 leave; `reapClosing` now drains once before freeing). 1079 leave; `reapClosing` now drains once before freeing).
1080 1080
1081 **Three hardening items in that batch are reasoned, not mutation-pinned**, 1081 **The asserts live only where asserts live.** `std.debug.assert` is compiled
1082 and are recorded as such because the batch would otherwise read as 1082 out of ReleaseFast and ReleaseSmall. Every binary this project builds today
1083 uniformly verified: `closed`-recomputed-after-reap needs a handler that 1083 is Debug — `make build`, `make test`, `make e2e`, the musl cross-build — so
1084 closes a *different* connection mid-reap; took-before-error-check needs 1084 the guard is present everywhere it currently matters. The day a release
1085 ngtcp2 to commit a stream offset and then fail; the two `deinit` orderings 1085 build is added, the assert stops being a tripwire and re-entrancy reverts to
1086 need a use-after-free detector. All three are corrections to paths the tests 1086 what it was: a rare abort with no banner. The invariant is enforced by the
1087 do not reach. 1087 structure (queue-only `send`); the assert only makes a regression *loud*,
1088 and only in the builds we ship today.
1089
1090 **Two hardening items remain reasoned rather than mutation-pinned**, down
1091 from three, and are recorded as such because the batch would otherwise read
1092 as uniformly verified:
1093
1094 - `closed`-recomputed-after-reap. Constructible, and the construction is
1095 known — two connections, where `onClose(A)` closes B, triggered by a
1096 packet on B whose `onData` closes A — so this is a time trade rather than
1097 an impossibility. Left undone deliberately.
1098 - The two `deinit` orderings. The honest instrument is ASAN or valgrind over
1099 the QUIC tests, not a cleverer unit test; a use-after-free that reads
1100 recycled memory successfully is exactly the failure a functional test
1101 cannot see. Banked as suite investment.
1102
1103 took-before-error-check is no longer among them: extracting `accountWrite`
1104 made the ordering a property of one small function, which a unit test pins
1105 directly (`wrote=4, n=-1` must account the four and then stop) without
1106 needing to make ngtcp2 fail on demand.
1088 1107
1089 ### Reconcile v2: judge evidence, not arrival order 1108 ### Reconcile v2: judge evidence, not arrival order
1090 1109
@@ -1236,6 +1255,10 @@ Engine, over a socket. The full render-vs-dump harness stays banked.
1236 before it is. 1255 before it is.
1237 - **Full render-vs-dump convergence harness** (shared with M7's identical 1256 - **Full render-vs-dump convergence harness** (shared with M7's identical
1238 banked item). 1257 banked item).
1258 - **ASAN or valgrind over the QUIC tests.** The only honest instrument for
1259 the two `deinit` orderings above, and for the whole class of
1260 use-after-free that reads recycled memory successfully — which is the
1261 class this project has already been bitten by twice.
1239 - **Floor-form rewrite applied retroactively to M6's reattach criterion.** 1262 - **Floor-form rewrite applied retroactively to M6's reattach criterion.**
1240 It still fails structurally on fast links; the form that fixes it is now 1263 It still fails structurally on fast links; the form that fixes it is now
1241 standard and written down. 1264 standard and written down.
src/quic_client.zig
Old New
@@ -437,16 +437,14 @@ pub const Client = struct {
437 stream_blocked = true; 437 stream_blocked = true;
438 continue; 438 continue;
439 } 439 }
440 // Accounted before the error check: ngtcp2 can commit the stream 440 switch (quic.accountWrite(&self.out, wrote, n)) {
441 // offset and still fail afterwards, and bytes left counted as 441 .stop => {
442 // unsent get re-offered at an offset the peer has moved past — 442 self.dead = true;
443 // a desynchronised stream that surfaces far from here. 443 return;
444 if (wrote > 0) self.out.took(@intCast(wrote)); 444 },
445 if (n < 0) { 445 .brk => return,
446 self.dead = true; 446 .cont => {},
447 return;
448 } 447 }
449 if (n == 0) return;
450 _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch return; 448 _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch return;
451 } 449 }
452 } 450 }
src/quic_server.zig
Old New
@@ -361,6 +361,31 @@ pub const Egress = struct {
361 } 361 }
362 }; 362 };
363 363
364 /// What one `writev_stream` return means for the egress ring — and, more to
365 /// the point, IN WHAT ORDER.
366 ///
367 /// The ordering is the whole reason this is a function rather than three
368 /// copies of two ifs. ngtcp2 can commit `ndatalen` — advancing the stream
369 /// offset it will retransmit from — and still return an error afterwards
370 /// (NOMEM out of rtb_add, say). Account for the bytes first or they stay
371 /// counted as unsent, get offered again at an offset the peer has moved
372 /// past, and the stream desynchronises somewhere far away from here.
373 pub const WriteAction = enum {
374 /// The call failed. Stop draining; the bytes are already accounted.
375 stop,
376 /// Nothing more to send right now.
377 brk,
378 /// A packet was produced; send it and go round again.
379 cont,
380 };
381
382 pub fn accountWrite(out: *Egress, wrote: c.ngtcp2_ssize, n: c.ngtcp2_ssize) WriteAction {
383 if (wrote > 0) out.took(@intCast(wrote));
384 if (n < 0) return .stop;
385 if (n == 0) return .brk;
386 return .cont;
387 }
388
364 /// One authenticated peer: an ngtcp2 connection, its TLS object, and the 389 /// One authenticated peer: an ngtcp2 connection, its TLS object, and the
365 /// single bidirectional stream that carries everything. 390 /// single bidirectional stream that carries everything.
366 const Conn = struct { 391 const Conn = struct {
@@ -1195,16 +1220,11 @@ pub const Listener = struct {
1195 stream_blocked = true; 1220 stream_blocked = true;
1196 continue; 1221 continue;
1197 } 1222 }
1198 // Accounted BEFORE the error check, because ngtcp2 can commit 1223 switch (accountWrite(&cn.out, wrote, n)) {
1199 // ndatalen — advancing the stream offset it will retransmit 1224 .stop => return,
1200 // from — and still return an error afterwards (NOMEM out of 1225 .brk => break,
1201 // rtb_add, say). Returning first left those bytes counted as 1226 .cont => {},
1202 // unsent, so the next drain offered them again at a new offset: 1227 }
1203 // the stream desynchronises and presents far away as a frame
1204 // mismatch.
1205 if (wrote > 0) cn.out.took(@intCast(wrote));
1206 if (n < 0) return;
1207 if (n == 0) break;
1208 1228
1209 _ = std.posix.sendto( 1229 _ = std.posix.sendto(
1210 self.fd, 1230 self.fd,
@@ -1689,6 +1709,15 @@ test "Listener: PSK handshake, Retry, and a payload larger than the initial wind
1689 try std.testing.expect(ok); 1709 try std.testing.expect(ok);
1690 try std.testing.expectEqual(@as(usize, 3 * 1024 * 1024), owner.received); 1710 try std.testing.expectEqual(@as(usize, 3 * 1024 * 1024), owner.received);
1691 try std.testing.expect(!cl.mismatch); 1711 try std.testing.expect(!cl.mismatch);
1712
1713 // Piggybacking on the one place a LIVE connection with a finite expiry
1714 // exists: negative is poll(2)'s "wait forever", and the timeout fold
1715 // must hand it back rather than try to @intCast it. Without the guard
1716 // this line panics — and it needs a real connection, because the loop
1717 // skips connections with no expiry and would never reach the cast.
1718 try std.testing.expectEqual(@as(i32, -1), setup.l.timeoutMs(-1));
1719 // The ordinary case still folds as before.
1720 try std.testing.expect(setup.l.timeoutMs(100) <= 100);
1692 } 1721 }
1693 1722
1694 test "Listener: a client holding the wrong key never completes a handshake" { 1723 test "Listener: a client holding the wrong key never completes a handshake" {
@@ -1829,6 +1858,27 @@ test "Egress: an ack can never free more than is outstanding" {
1829 try std.testing.expectEqualStrings("cd", v[0].base[0..v[0].len]); 1858 try std.testing.expectEqualStrings("cd", v[0].base[0..v[0].len]);
1830 } 1859 }
1831 1860
1861 test "accountWrite: bytes ngtcp2 committed are accounted even when the call failed" {
1862 const alloc = std.testing.allocator;
1863 var e: Egress = .{ .buf = try alloc.alloc(u8, 16) };
1864 defer e.deinit(alloc);
1865 _ = e.push("abcdefgh");
1866 try std.testing.expectEqual(@as(usize, 8), e.unsent);
1867
1868 // The case that has no fault injection available and would otherwise go
1869 // untested: ngtcp2 committed four bytes of stream data — its offset has
1870 // moved — and THEN returned an error. Accounting after the check would
1871 // leave those four counted as unsent, so the next drain would offer them
1872 // again at an offset the peer is already past.
1873 try std.testing.expectEqual(WriteAction.stop, accountWrite(&e, 4, -1));
1874 try std.testing.expectEqual(@as(usize, 4), e.unsent);
1875
1876 // The ordinary returns, for completeness of the contract.
1877 try std.testing.expectEqual(WriteAction.brk, accountWrite(&e, 0, 0));
1878 try std.testing.expectEqual(WriteAction.cont, accountWrite(&e, 4, 120));
1879 try std.testing.expectEqual(@as(usize, 0), e.unsent);
1880 }
1881
1832 test "Egress: an ack against a torn-down ring is ignored, not a division by zero" { 1882 test "Egress: an ack against a torn-down ring is ignored, not a division by zero" {
1833 const alloc = std.testing.allocator; 1883 const alloc = std.testing.allocator;
1834 var e: Egress = .{ .buf = try alloc.alloc(u8, 8) }; 1884 var e: Egress = .{ .buf = try alloc.alloc(u8, 8) };
@@ -2174,6 +2224,93 @@ fn probeGotAnything(fd: std.posix.fd_t) bool {
2174 return n > 0; 2224 return n > 0;
2175 } 2225 }
2176 2226
2227 test "Listener: a reply queued just before a close still reaches the peer" {
2228 const alloc = std.testing.allocator;
2229 const key: Key = .{ .bytes = [_]u8{0x5C} ** key_len };
2230
2231 // The session-full refusal, in miniature: the owner answers and then
2232 // shuts the connection, both from inside one callback. Since `send` only
2233 // QUEUES — draining from inside an ngtcp2 callback is the defect that
2234 // change removed — those bytes have exactly one chance to leave, in the
2235 // drain reapClosing does before it frees the connection. Delete that
2236 // drain and a refused client learns nothing and waits out its idle
2237 // timeout instead.
2238 //
2239 // The guarantee is bounded by the peer's flow-control window at that
2240 // instant. A refusal is a handful of bytes and always fits; a large
2241 // queued payload would not, and this test does not claim otherwise.
2242 const AnswerThenClose = struct {
2243 listener: *Listener = undefined,
2244 id: u64 = 0,
2245 opened: usize = 0,
2246 closed: usize = 0,
2247 queued: usize = 0,
2248
2249 fn onOpen(ctx: *anyopaque, id: u64) void {
2250 const self: *@This() = @ptrCast(@alignCast(ctx));
2251 self.opened += 1;
2252 self.id = id;
2253 }
2254 fn onData(ctx: *anyopaque, id: u64, _: []const u8) void {
2255 const self: *@This() = @ptrCast(@alignCast(ctx));
2256 if (self.queued > 0) return;
2257 self.id = id;
2258 self.queued = self.listener.send(id, "REFUSED-FULL") catch 0;
2259 self.listener.closeConn(id);
2260 }
2261 fn onClose(ctx: *anyopaque, _: u64) void {
2262 const self: *@This() = @ptrCast(@alignCast(ctx));
2263 self.closed += 1;
2264 }
2265 fn handler(self: *@This()) Handler {
2266 return .{ .ctx = self, .onOpen = onOpen, .onData = onData, .onClose = onClose };
2267 }
2268 };
2269
2270 var owner: AnswerThenClose = .{};
2271 const bind = try std.net.Address.parseIp("127.0.0.1", 0);
2272 const l = try Listener.init(alloc, bind, key, owner.handler(), 10_000);
2273 defer l.deinit();
2274 owner.listener = l;
2275 var actual: std.posix.sockaddr.storage = undefined;
2276 var alen: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
2277 try std.posix.getsockname(l.fd, @ptrCast(&actual), &alen);
2278 const addr = std.net.Address.initPosix(@ptrCast(@alignCast(&actual)));
2279
2280 var cl = try TestClient.init(addr, key);
2281 defer cl.deinit();
2282 try cl.start();
2283 cl.drain();
2284
2285 cl.out = "attach";
2286 var waited: u64 = 0;
2287 while (waited < 10_000) : (waited += 5) {
2288 var fds = [_]std.posix.pollfd{
2289 .{ .fd = l.fd, .events = std.posix.POLL.IN, .revents = 0 },
2290 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 },
2291 };
2292 _ = std.posix.poll(&fds, 5) catch break;
2293 if (fds[0].revents != 0) l.readable();
2294 if (fds[1].revents != 0) cl.readable();
2295 l.tick();
2296 cl.drain();
2297 for (l.conns) |slot| {
2298 if (slot) |cn| l.drain(cn);
2299 }
2300 if (cl.recv_len >= "REFUSED-FULL".len) break;
2301 }
2302
2303 try std.testing.expectEqual(@as(usize, "REFUSED-FULL".len), owner.queued);
2304 // The bytes are on the peer, not merely in a ring that was then freed.
2305 try std.testing.expect(std.mem.indexOf(
2306 u8,
2307 cl.recv_buf[0..cl.recv_len],
2308 "REFUSED-FULL",
2309 ) != null);
2310 // ...and the connection really did go.
2311 try std.testing.expect(l.find(owner.id) == null);
2312 }
2313
2177 test "Listener: closing a connection from inside a receive callback is deferred" { 2314 test "Listener: closing a connection from inside a receive callback is deferred" {
2178 const alloc = std.testing.allocator; 2315 const alloc = std.testing.allocator;
2179 const key: Key = .{ .bytes = [_]u8{0x7E} ** key_len }; 2316 const key: Key = .{ .bytes = [_]u8{0x7E} ** key_len };
src/server.zig
Old New
@@ -3733,6 +3733,96 @@ fn attachOver(cl: *quic.TestClient, buf: *std.ArrayList(u8), alloc: std.mem.Allo
3733 cl.drain(); 3733 cl.drain();
3734 } 3734 }
3735 3735
3736 test "Server: output reaches a silent QUIC client without waiting for it to speak" {
3737 const alloc = std.testing.allocator;
3738 var tmp = try TmpDir.make();
3739 defer tmp.cleanup();
3740 const dir_path = tmp.path();
3741 const sock_path = try std.fmt.allocPrint(alloc, "{s}/qquiet.sock", .{dir_path});
3742 defer alloc.free(sock_path);
3743
3744 // /bin/cat: a session that emits nothing on its own, so the only output
3745 // in this test is the output the test causes.
3746 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
3747 defer srv.deinit();
3748 const key: quic.Key = .{ .bytes = [_]u8{0x4D} ** quic.key_len };
3749 const q = try quicTestServer(&srv, key);
3750 defer q.l.deinit();
3751
3752 var cl = try quic.TestClient.init(q.addr, key);
3753 defer cl.deinit();
3754 try cl.start();
3755 cl.drain();
3756
3757 var out: std.ArrayList(u8) = .empty;
3758 defer out.deinit(alloc);
3759 try attachOver(&cl, &out, alloc);
3760
3761 var only = [_]*quic.TestClient{&cl};
3762 _ = try quicPump(&srv, &only, 8000, &cl, struct {
3763 fn f(t: *quic.TestClient) bool {
3764 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null;
3765 }
3766 }.f);
3767 try std.testing.expect(findFrame(cl.recv_buf[0..cl.recv_len], .snapshot) != null);
3768
3769 // From here the client says NOTHING. That is the whole point: the
3770 // listener's send only queues, and the two drains that fire on their own
3771 // are the one after read_pkt — which needs an inbound packet — and tick,
3772 // which only services connections whose timer is due. A broadcast to a
3773 // quiet client is covered by neither, and the end-of-pump drainAll is
3774 // the only thing that moves it.
3775 // Quiesce first, or the measurement is meaningless. Straight after the
3776 // attach exchange ngtcp2 has a loss-detection timer already past due, so
3777 // tick() drains on expiry and delivers the frame below whether or not
3778 // anything else does. Settle until the next timer is comfortably in the
3779 // future; only then is tick ruled out as the deliverer.
3780 var settle: usize = 0;
3781 while (settle < 400) : (settle += 1) {
3782 if (q.l.timeoutMs(1000) > 100) break;
3783 _ = try srv.pumpOnce(5);
3784 cl.drain();
3785 var pfd = [_]std.posix.pollfd{
3786 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 },
3787 };
3788 if ((std.posix.poll(&pfd, 1) catch 0) > 0) cl.readable();
3789 }
3790 try std.testing.expect(q.l.timeoutMs(1000) > 100);
3791
3792 const before = cl.recv_len;
3793 cl.out = &.{};
3794 srv.eng.feed("quiet-client-marker\r\n");
3795 srv.sendUpdate();
3796
3797 // EXACTLY ONE pump, and the client only ever listens. Both halves are
3798 // the test, and both were learned by watching weaker versions pass:
3799 //
3800 // quicPump calls cl.drain() every iteration, which makes the client
3801 // TRANSMIT — its acknowledgements alone suffice — and every inbound
3802 // packet hands feed() a drain to carry the reply out on. And looping
3803 // over several pumps is no better: on loopback the PTO expiry comes due
3804 // within a few hundred milliseconds, and tick drains on expiry, so any
3805 // budget generous enough not to be flaky is generous enough to pass
3806 // without drainAll existing at all.
3807 //
3808 // One pump answers the only question that isolates it: the frame was
3809 // queued before this call, so did THIS call put it on the wire?
3810 _ = try srv.pumpOnce(5);
3811
3812 // Reading is not transmitting: the client takes whatever already
3813 // arrived, and never gives the server an inbound packet to react to.
3814 var tries: usize = 0;
3815 while (tries < 60 and findFrame(cl.recv_buf[0..cl.recv_len], .delta) == null) : (tries += 1) {
3816 var pfd = [_]std.posix.pollfd{
3817 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 },
3818 };
3819 if ((std.posix.poll(&pfd, 2) catch 0) > 0) cl.readable();
3820 }
3821
3822 try std.testing.expect(cl.recv_len > before);
3823 try std.testing.expect(findFrame(cl.recv_buf[0..cl.recv_len], .delta) != null);
3824 }
3825
3736 test "Server: a QUIC client still receives the shell's exit status" { 3826 test "Server: a QUIC client still receives the shell's exit status" {
3737 const alloc = std.testing.allocator; 3827 const alloc = std.testing.allocator;
3738 var tmp = try TmpDir.make(); 3828 var tmp = try TmpDir.make();