a73x

56ca01d6

refactor: quic.zig follow-through — client callbacks dedup, typed pathFrom, honest topology prose

a73x   2026-08-12 18:24

Commit message
refactor: quic.zig follow-through — client callbacks dedup, typed pathFrom, honest topology prose

Review follow-up to b34e7a1.

quic_client.zig carried private randCb/getNewCidCb that were byte-identical
to quic.zig's (verified mechanically, not by eye). Deleted; the two callback
assignments take quic.randCb/quic.getNewCidCb. The client needs no variant —
the listener's serverGetNewCidCb wrapper is what a real one looks like, and
it exists because it marks a Conn's CID cache stale.

pathFrom drops anytype for the concrete types all six call sites already
pass, so a wrong argument is a signature mismatch rather than a surprise
inside the body; socklen_t is now std.posix's on both parameters.

Prose caught up with the topology: build.zig no longer claims the client
imports the listener's module (it imports the vocabulary), quic.zig states
the one-cImport ownership rule, quic_server.zig's header admits it also
houses TestClient, and the two "Task 3's client transport" forward
references name quic_client.zig, which has existed for some time.

quic.zig gains the section banners quic_server.zig uses; the two
wire-constant clumps merge into one block, key_len sits with Key, and every
test travels with its subject. Reordering only — no name, literal or doc
line changed, checked by line-multiset diff.

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

build.zig
Old New
@@ -191,10 +191,11 @@ pub fn build(b: *std.Build) void {
191 // daemon itself when nobody handed it a --key. 191 // daemon itself when nobody handed it a --key.
192 server_mod.addImport("xdg", xdg_mod); 192 server_mod.addImport("xdg", xdg_mod);
193 193
194 // The client's QUIC transport. Imports the listener's module for the 194 // The client's QUIC transport. Imports the vocabulary module — NOT the
195 // pieces both ends must agree on (the key, the egress ring's lifetime 195 // listener — for the pieces both ends must agree on (the key, the egress
196 // discipline, the PSK identity and ALPN) — duplicating those would make 196 // ring's lifetime discipline, the PSK identity and ALPN); duplicating
197 // a handshake failure the first sign they had drifted. 197 // those would make a handshake failure the first sign they had drifted.
198 // It has no business knowing a listener exists, and now it cannot.
198 const quic_client_mod = b.createModule(.{ 199 const quic_client_mod = b.createModule(.{
199 .root_source_file = b.path("src/quic_client.zig"), 200 .root_source_file = b.path("src/quic_client.zig"),
200 .target = target, 201 .target = target,
src/quic.zig
Old New
@@ -5,6 +5,13 @@
5 //! owns the listener, quic_client.zig owns the client transport, and both 5 //! owns the listener, quic_client.zig owns the client transport, and both
6 //! import this — which is what makes "the two ends agree" a fact about one 6 //! import this — which is what makes "the two ends agree" a fact about one
7 //! file rather than a convention two files are trusted to keep. 7 //! file rather than a convention two files are trusted to keep.
8 //!
9 //! THE OWNERSHIP RULE, because it is load-bearing rather than tidy: this
10 //! file holds the one and only @cImport of the QUIC stack. Two blocks over
11 //! the same headers produce two *distinct* Zig types, so an `ngtcp2_vec`
12 //! minted under one would not be an `ngtcp2_vec` to the other — and the
13 //! egress ring hands exactly those across the seam between listener and
14 //! client. A new QUIC file therefore imports this one; it never cImports.
8 const std = @import("std"); 15 const std = @import("std");
9 16
10 /// The C view of the QUIC stack. Exported because the client transport 17 /// The C view of the QUIC stack. Exported because the client transport
@@ -19,7 +26,9 @@ pub const c = @cImport({
19 @cInclude("wolfssl/ssl.h"); 26 @cInclude("wolfssl/ssl.h");
20 }); 27 });
21 28
22 pub const key_len = 32; 29 // ---------------------------------------------------------------------------
30 // Wire constants: the numbers and strings both ends must spell alike
31 // ---------------------------------------------------------------------------
23 32
24 /// mux's conventional QUIC port. Both parsers reach for it when the user 33 /// mux's conventional QUIC port. Both parsers reach for it when the user
25 /// names no port; it lives here because this module is the one thing both 34 /// names no port; it lives here because this module is the one thing both
@@ -38,6 +47,24 @@ pub const default_port: u16 = 4433;
38 /// binaries default to are two numbers, and they drift in silence. 47 /// binaries default to are two numbers, and they drift in silence.
39 pub const default_idle_ms: u32 = 15_000; 48 pub const default_idle_ms: u32 = 15_000;
40 49
50 pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting
51
52 pub const psk_identity: [*:0]const u8 = "mux";
53 pub const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256";
54 pub const alpn = "\x03mux";
55
56 /// How many outbound bytes one connection may hold. Sized to the stream
57 /// window the peer advertises, because holding much more than the peer will
58 /// let us send buys nothing: past this the daemon's own `pending_cap` is the
59 /// right place for the backlog to sit and be judged.
60 pub const egress_cap = 256 * 1024;
61
62 // ---------------------------------------------------------------------------
63 // The pre-shared key
64 // ---------------------------------------------------------------------------
65
66 pub const key_len = 32;
67
41 /// The pre-shared key, and the rules for getting one off disk. 68 /// The pre-shared key, and the rules for getting one off disk.
42 /// 69 ///
43 /// A key file is exactly as sensitive as an ssh private key, so it is held 70 /// A key file is exactly as sensitive as an ssh private key, so it is held
@@ -208,17 +235,9 @@ test "Key.load: refuses a permissive mode, a missing file, and a bad length" {
208 ); 235 );
209 } 236 }
210 237
211 pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting 238 // ---------------------------------------------------------------------------
212 239 // Egress: the ring, and what one writev_stream return means for it
213 pub const psk_identity: [*:0]const u8 = "mux"; 240 // ---------------------------------------------------------------------------
214 pub const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256";
215 pub const alpn = "\x03mux";
216
217 /// How many outbound bytes one connection may hold. Sized to the stream
218 /// window the peer advertises, because holding much more than the peer will
219 /// let us send buys nothing: past this the daemon's own `pending_cap` is the
220 /// right place for the backlog to sit and be judged.
221 pub const egress_cap = 256 * 1024;
222 241
223 /// Outbound stream bytes, in a ring that never moves a byte once written. 242 /// Outbound stream bytes, in a ring that never moves a byte once written.
224 /// 243 ///
@@ -422,6 +441,10 @@ test "Egress: an ack against a torn-down ring is ignored, not a division by zero
422 try std.testing.expectEqual(@as(usize, 0), e.held); 441 try std.testing.expectEqual(@as(usize, 0), e.held);
423 } 442 }
424 443
444 // ---------------------------------------------------------------------------
445 // Time, and the keepalive derived from it
446 // ---------------------------------------------------------------------------
447
425 pub fn timestampNs() u64 { 448 pub fn timestampNs() u64 {
426 const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0; 449 const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0;
427 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); 450 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
@@ -444,6 +467,10 @@ test "keepAlive: a third of the idle timeout, and never disabled" {
444 try std.testing.expectEqual(@as(u64, 1_000_000), keepAliveNs(2)); 467 try std.testing.expectEqual(@as(u64, 1_000_000), keepAliveNs(2));
445 } 468 }
446 469
470 // ---------------------------------------------------------------------------
471 // ngtcp2 callbacks with no per-endpoint variation
472 // ---------------------------------------------------------------------------
473
447 pub fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void { 474 pub fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void {
448 std.crypto.random.bytes(dest[0..destlen]); 475 std.crypto.random.bytes(dest[0..destlen]);
449 } 476 }
@@ -461,9 +488,18 @@ pub fn getNewCidCb(
461 return 0; 488 return 0;
462 } 489 }
463 490
491 // ---------------------------------------------------------------------------
492 // Paths
493 // ---------------------------------------------------------------------------
494
464 /// The ngtcp2_path literal, spelled once. Six call sites built this 495 /// The ngtcp2_path literal, spelled once. Six call sites built this
465 /// by hand and the seventh would have drifted. 496 /// by hand and the seventh would have drifted.
466 pub fn pathFrom(local: anytype, local_len: c.socklen_t, remote: anytype, remote_len: c.socklen_t) c.ngtcp2_path { 497 pub fn pathFrom(
498 local: *std.posix.sockaddr.storage,
499 local_len: std.posix.socklen_t,
500 remote: *std.posix.sockaddr.storage,
501 remote_len: std.posix.socklen_t,
502 ) c.ngtcp2_path {
467 return .{ 503 return .{
468 .local = .{ .addr = @ptrCast(local), .addrlen = local_len }, 504 .local = .{ .addr = @ptrCast(local), .addrlen = local_len },
469 .remote = .{ .addr = @ptrCast(remote), .addrlen = remote_len }, 505 .remote = .{ .addr = @ptrCast(remote), .addrlen = remote_len },
src/quic_client.zig
Old New
@@ -7,8 +7,13 @@
7 //! keeps "the transport is a swap" a checkable claim rather than a slogan, 7 //! keeps "the transport is a swap" a checkable claim rather than a slogan,
8 //! and it is why there is no `proto` import here. 8 //! and it is why there is no `proto` import here.
9 //! 9 //!
10 //! Three things in here are carried over from the daemon's side rather than 10 //! The pieces both ends must agree on are IMPORTED from quic.zig, not
11 //! rediscovered, because they were paid for once already: 11 //! reimplemented here: the key, the wire constants, the `Egress` ring, the
12 //! write accounting, the clock. That is why a drift between the two sides
13 //! is not a thing that can happen quietly — there is only one copy to drift.
14 //!
15 //! Three of those imports carry hard-won reasons with them, restated here
16 //! because the cost of relearning them is measured in freezes:
12 //! 17 //!
13 //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is 18 //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is
14 //! handed and re-reads those bytes to retransmit, so outbound bytes 19 //! handed and re-reads those bytes to retransmit, so outbound bytes
@@ -24,9 +29,9 @@
24 const std = @import("std"); 29 const std = @import("std");
25 const quic = @import("quic"); 30 const quic = @import("quic");
26 31
27 /// Shared with the listener rather than imported again: two @cImport blocks 32 /// quic.zig's cImport, and deliberately not one of our own: two @cImport
28 /// over the same headers are two distinct type universes, and the egress 33 /// blocks over the same headers are two distinct type universes, and the
29 /// ring hands `ngtcp2_vec`s across this boundary. 34 /// egress ring hands `ngtcp2_vec`s across this boundary.
30 const c = quic.c; 35 const c = quic.c;
31 36
32 pub const Key = quic.Key; 37 pub const Key = quic.Key;
@@ -61,23 +66,6 @@ fn getConnCb(ref: [*c]c.ngtcp2_crypto_conn_ref) callconv(.c) ?*c.ngtcp2_conn {
61 return self.conn; 66 return self.conn;
62 } 67 }
63 68
64 fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void {
65 std.crypto.random.bytes(dest[0..destlen]);
66 }
67
68 fn getNewCidCb(
69 _: ?*c.ngtcp2_conn,
70 cid: [*c]c.ngtcp2_cid,
71 token: [*c]c.ngtcp2_stateless_reset_token,
72 cidlen: usize,
73 _: ?*anyopaque,
74 ) callconv(.c) c_int {
75 std.crypto.random.bytes(cid.*.data[0..cidlen]);
76 cid.*.datalen = cidlen;
77 std.crypto.random.bytes(&token.*.data);
78 return 0;
79 }
80
81 fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, ud: ?*anyopaque) callconv(.c) c_int { 69 fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, ud: ?*anyopaque) callconv(.c) c_int {
82 const self: *Client = @ptrCast(@alignCast(ud.?)); 70 const self: *Client = @ptrCast(@alignCast(ud.?));
83 self.handshake_done = true; 71 self.handshake_done = true;
@@ -252,8 +240,8 @@ pub const Client = struct {
252 cbs.delete_crypto_cipher_ctx = c.ngtcp2_crypto_delete_crypto_cipher_ctx_cb; 240 cbs.delete_crypto_cipher_ctx = c.ngtcp2_crypto_delete_crypto_cipher_ctx_cb;
253 cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb; 241 cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb;
254 cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb; 242 cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb;
255 cbs.rand = randCb; 243 cbs.rand = quic.randCb;
256 cbs.get_new_connection_id2 = getNewCidCb; 244 cbs.get_new_connection_id2 = quic.getNewCidCb;
257 cbs.handshake_completed = handshakeCompletedCb; 245 cbs.handshake_completed = handshakeCompletedCb;
258 cbs.extend_max_local_streams_bidi = extendStreamsCb; 246 cbs.extend_max_local_streams_bidi = extendStreamsCb;
259 cbs.recv_stream_data = recvStreamDataCb; 247 cbs.recv_stream_data = recvStreamDataCb;
src/quic_server.zig
Old New
@@ -1,7 +1,12 @@
1 //! muxd's QUIC listener, and only the listener: one UDP socket, N 1 //! muxd's QUIC listener: one UDP socket, N authenticated connections, each
2 //! authenticated connections, each carrying exactly one bidirectional stream 2 //! carrying exactly one bidirectional stream of opaque bytes. The vocabulary
3 //! of opaque bytes. The vocabulary both ends share — the C import, the key, 3 //! both ends share — the C import, the key, the egress ring — is quic.zig's,
4 //! the egress ring — is quic.zig's, and this file imports it. 4 //! and this file imports it.
5 //!
6 //! Two things live here, not one. Below the test banner further down sits
7 //! `TestClient`, a real QUIC peer the daemon's own tests drive; it is
8 //! exported for server.zig's benefit and is scaffolding, not a transport.
9 //! The shipping client is quic_client.zig.
5 //! 10 //!
6 //! This file follows proxy.zig's discipline and for the same reason: it 11 //! This file follows proxy.zig's discipline and for the same reason: it
7 //! knows NOTHING about the frame protocol it carries. It moves bytes 12 //! knows NOTHING about the frame protocol it carries. It moves bytes
@@ -942,9 +947,10 @@ pub const Listener = struct {
942 // --------------------------------------------------------------------------- 947 // ---------------------------------------------------------------------------
943 // Tests: a real handshake against a real client, in one process. 948 // Tests: a real handshake against a real client, in one process.
944 // 949 //
945 // The client below is test scaffolding, not a preview of Task 3's transport. 950 // The client below is test scaffolding, and not a stand-in for the real
946 // It exists because the listener cannot be tested by inspection: a QUIC 951 // one: quic_client.zig is the shipping transport. It exists because the
947 // handshake either completes against a real peer or it does not. 952 // listener cannot be tested by inspection — a QUIC handshake either
953 // completes against a real peer or it does not.
948 // --------------------------------------------------------------------------- 954 // ---------------------------------------------------------------------------
949 955
950 var g_client_key: quic.Key = undefined; 956 var g_client_key: quic.Key = undefined;
@@ -968,8 +974,8 @@ fn pskClientCb(
968 } 974 }
969 975
970 /// Test scaffolding, exported so the daemon's own tests can drive a real 976 /// Test scaffolding, exported so the daemon's own tests can drive a real
971 /// QUIC peer. Not a preview of Task 3's client transport: no reconnect, no 977 /// QUIC peer. Not the client transport — that is quic_client.zig — and
972 /// resumption, and a fixed receive buffer. 978 /// deliberately less: no reconnect, no resumption, a fixed receive buffer.
973 pub const TestClient = struct { 979 pub const TestClient = struct {
974 fd: std.posix.fd_t, 980 fd: std.posix.fd_t,
975 ssl_ctx: ?*c.WOLFSSL_CTX, 981 ssl_ctx: ?*c.WOLFSSL_CTX,