ba62a7b2
test: pin the three QUIC fixes a mutation could still walk through
a73x 2026-08-08 17:42
Commit message
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(); |