a73x

ae0b1138

feat: muxd speaks QUIC — one UDP fd, PSK, and the protocol never noticed

a73x   2026-08-08 14:08

Commit message
feat: muxd speaks QUIC — one UDP fd, PSK, and the protocol never noticed

build.zig
Old New
@@ -99,6 +99,7 @@ pub fn build(b: *std.Build) void {
99 server_mod.addImport("engine", engine_mod); 99 server_mod.addImport("engine", engine_mod);
100 server_mod.addImport("pty", pty_mod); 100 server_mod.addImport("pty", pty_mod);
101 server_mod.addImport("protocol", protocol_mod); 101 server_mod.addImport("protocol", protocol_mod);
102 server_mod.addImport("quic", quic_mod);
102 103
103 const client_mod = b.createModule(.{ 104 const client_mod = b.createModule(.{
104 .root_source_file = b.path("src/client.zig"), 105 .root_source_file = b.path("src/client.zig"),
@@ -135,6 +136,9 @@ pub fn build(b: *std.Build) void {
135 exe_mod.addImport("server", server_mod); 136 exe_mod.addImport("server", server_mod);
136 exe_mod.addImport("protocol", protocol_mod); 137 exe_mod.addImport("protocol", protocol_mod);
137 exe_mod.addImport("proxy", proxy_mod); 138 exe_mod.addImport("proxy", proxy_mod);
139 // The daemon entrypoint loads the key and constructs the listener, so it
140 // needs the module directly rather than through the server.
141 exe_mod.addImport("quic", quic_mod);
138 142
139 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 143 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
140 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 144 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
@@ -152,9 +156,12 @@ pub fn build(b: *std.Build) void {
152 b.installArtifact(mux_exe); 156 b.installArtifact(mux_exe);
153 157
154 const test_step = b.step("test", "Run unit tests"); 158 const test_step = b.step("test", "Run unit tests");
155 // mux_mod is an executable root, but it carries the argument parser, and 159 // mux_mod and exe_mod are executable roots, but they carry the argument
156 // a test that is never built is not a test. 160 // parsers, and a test that is never built is not a test. exe_mod's
157 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod }) |mod| { 161 // absence here was a live hazard recorded in decisions.md — muxd's
162 // entrypoint could grow tests that silently never ran, exactly as
163 // mux_main.zig's five did before it was added.
164 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod }) |mod| {
158 const t = b.addTest(.{ .root_module = mod }); 165 const t = b.addTest(.{ .root_module = mod });
159 t.use_llvm = true; 166 t.use_llvm = true;
160 t.use_lld = true; 167 t.use_lld = true;
@@ -164,7 +171,7 @@ pub fn build(b: *std.Build) void {
164 // deps when they are absent. Without it the dependency reached only 171 // deps when they are absent. Without it the dependency reached only
165 // `muxd`, and a clean checkout running `make test` first would have 172 // `muxd`, and a clean checkout running `make test` first would have
166 // found no libraries and no explanation. 173 // found no libraries and no explanation.
167 if (mod == server_mod or mod == quic_mod) linkQuic(b, t, quic); 174 if (mod == server_mod or mod == quic_mod or mod == exe_mod) linkQuic(b, t, quic);
168 test_step.dependOn(&b.addRunArtifact(t).step); 175 test_step.dependOn(&b.addRunArtifact(t).step);
169 } 176 }
170 177
src/main.zig
Old New
@@ -5,104 +5,312 @@ const std = @import("std");
5 const Server = @import("server").Server; 5 const Server = @import("server").Server;
6 const proto = @import("protocol"); 6 const proto = @import("protocol");
7 const proxy = @import("proxy"); 7 const proxy = @import("proxy");
8 const quic = @import("quic");
8 9
9 const usage = 10 const usage =
10 \\usage: 11 \\usage:
11 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] 12 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N]
13 \\ [--quic HOST:PORT --key FILE] [--quic-idle-ms N]
12 \\ muxd dump [--vt] [--sock PATH] 14 \\ muxd dump [--vt] [--sock PATH]
13 \\ muxd stats [--sock PATH] 15 \\ muxd stats [--sock PATH]
14 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) 16 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket)
15 \\ 17 \\
16 ; 18 ;
17 19
18 pub fn main() !u8 { 20 /// Long enough that a quiet terminal is not a suspicious one, short enough
19 var gpa: std.heap.DebugAllocator(.{}) = .init; 21 /// that a client which has genuinely vanished stops being served within a
20 defer _ = gpa.deinit(); 22 /// few seconds of keepalives failing. Tunable because the reconnect tests
21 const alloc = gpa.allocator(); 23 /// need death declared on a schedule they can wait for.
24 const default_quic_idle_ms: u32 = 15_000;
22 25
23 const args = try std.process.argsAlloc(alloc); 26 const Cmd = enum { run, dump, stats, proxy };
24 defer std.process.argsFree(alloc, args);
25 27
26 if (args.len < 2) { 28 /// Everything the command line can say, once. Parsed away from `main` so it
27 std.debug.print("{s}", .{usage}); 29 /// can be tested without a process to exit from — the same reason
28 return 2; 30 /// mux_main.zig's parser is its own function.
29 } 31 const Opts = struct {
32 cmd: Cmd,
33 sock: ?[]const u8 = null,
34 shell: ?[]const u8 = null,
35 cols: u16 = 80,
36 rows: u16 = 24,
37 vt: bool = false,
38 /// `--quic` and `--key` are both-or-neither, enforced here, which is
39 /// what makes `quic != null` licence to unwrap `key`.
40 quic: ?[]const u8 = null,
41 key: ?[]const u8 = null,
42 quic_idle_ms: u32 = default_quic_idle_ms,
43 };
44
45 /// A refusal, carrying whatever `main` needs to print one line about it.
46 /// None of these is a daemon bug, so none of them gets a stack trace.
47 const Usage = union(enum) {
48 no_command,
49 unknown_command: []const u8,
50 unknown_arg: []const u8,
51 /// A flag at the end of argv with nothing left to consume.
52 missing_value: []const u8,
53 /// The flag whose value would not parse as the number it wants.
54 bad_number: []const u8,
55 quic_without_key,
56 key_without_quic,
57 };
58
59 const ParseResult = union(enum) { ok: Opts, err: Usage };
60
61 fn parseArgs(args: []const [:0]const u8) ParseResult {
62 if (args.len < 2) return .{ .err = .no_command };
63 const cmd: Cmd = if (std.mem.eql(u8, args[1], "run"))
64 .run
65 else if (std.mem.eql(u8, args[1], "dump"))
66 .dump
67 else if (std.mem.eql(u8, args[1], "stats"))
68 .stats
69 else if (std.mem.eql(u8, args[1], "proxy"))
70 .proxy
71 else
72 return .{ .err = .{ .unknown_command = args[1] } };
30 73
31 var sock_arg: ?[]const u8 = null; 74 var o: Opts = .{ .cmd = cmd };
32 var shell_arg: ?[]const u8 = null;
33 var vt_mode = false;
34 var cols: u16 = 80;
35 var rows: u16 = 24;
36 var i: usize = 2; 75 var i: usize = 2;
37 while (i < args.len) : (i += 1) { 76 while (i < args.len) : (i += 1) {
38 const a = args[i]; 77 const a = args[i];
39 if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { 78 if (std.mem.eql(u8, a, "--vt")) {
40 i += 1; 79 o.vt = true;
41 sock_arg = args[i]; 80 continue;
42 } else if (std.mem.eql(u8, a, "--shell") and i + 1 < args.len) {
43 i += 1;
44 shell_arg = args[i];
45 } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) {
46 i += 1;
47 cols = try std.fmt.parseInt(u16, args[i], 10);
48 } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) {
49 i += 1;
50 rows = try std.fmt.parseInt(u16, args[i], 10);
51 } else if (std.mem.eql(u8, a, "--vt")) {
52 vt_mode = true;
53 } else {
54 std.debug.print("unknown argument: {s}\n{s}", .{ a, usage });
55 return 2;
56 } 81 }
82 // Every remaining flag takes a value, so the missing-value case is
83 // answered once here rather than at each arm — a flag with nothing
84 // after it used to fall through to "unknown argument", which named
85 // the wrong mistake.
86 const takes_value = std.mem.eql(u8, a, "--sock") or
87 std.mem.eql(u8, a, "--shell") or
88 std.mem.eql(u8, a, "--cols") or
89 std.mem.eql(u8, a, "--rows") or
90 std.mem.eql(u8, a, "--quic") or
91 std.mem.eql(u8, a, "--key") or
92 std.mem.eql(u8, a, "--quic-idle-ms");
93 if (!takes_value) return .{ .err = .{ .unknown_arg = a } };
94 if (i + 1 >= args.len) return .{ .err = .{ .missing_value = a } };
95 i += 1;
96 const v = args[i];
97 if (std.mem.eql(u8, a, "--sock")) {
98 o.sock = v;
99 } else if (std.mem.eql(u8, a, "--shell")) {
100 o.shell = v;
101 } else if (std.mem.eql(u8, a, "--quic")) {
102 o.quic = v;
103 } else if (std.mem.eql(u8, a, "--key")) {
104 o.key = v;
105 } else if (std.mem.eql(u8, a, "--cols")) {
106 o.cols = std.fmt.parseInt(u16, v, 10) catch return .{ .err = .{ .bad_number = a } };
107 } else if (std.mem.eql(u8, a, "--rows")) {
108 o.rows = std.fmt.parseInt(u16, v, 10) catch return .{ .err = .{ .bad_number = a } };
109 } else if (std.mem.eql(u8, a, "--quic-idle-ms")) {
110 // u32 rather than u64 so that an absurd value is a parse
111 // failure here instead of an overflow where it is multiplied
112 // out to nanoseconds. Zero is refused because ngtcp2 reads it
113 // as "no idle timeout", the opposite of what the flag says.
114 const n = std.fmt.parseInt(u32, v, 10) catch return .{ .err = .{ .bad_number = a } };
115 if (n == 0) return .{ .err = .{ .bad_number = a } };
116 o.quic_idle_ms = n;
117 }
118 }
119
120 // Both or neither. A key with nowhere to listen is as much a mistake as
121 // a listener with nothing to authenticate against — and there is no
122 // unauthenticated mode to fall back to, so neither can be a default.
123 if (o.quic != null and o.key == null) return .{ .err = .quic_without_key };
124 if (o.key != null and o.quic == null) return .{ .err = .key_without_quic };
125
126 return .{ .ok = o };
127 }
128
129 fn usageExit(u: Usage) u8 {
130 switch (u) {
131 .no_command => std.debug.print("{s}", .{usage}),
132 .unknown_command => std.debug.print("{s}", .{usage}),
133 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }),
134 .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }),
135 .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }),
136 .quic_without_key => std.debug.print(
137 "muxd: --quic needs --key; there is no unauthenticated mode\n",
138 .{},
139 ),
140 .key_without_quic => std.debug.print(
141 "muxd: --key without --quic has nothing to listen on; name both or neither\n",
142 .{},
143 ),
57 } 144 }
145 return 2;
146 }
147
148 /// `HOST:PORT` where HOST is a literal address — `127.0.0.1:4433`,
149 /// `0.0.0.0:4433`, `[::]:4433`. Deliberately no DNS: this is the address to
150 /// bind, and a name resolving to several is a question, not an answer.
151 fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } {
152 if (s.len > 0 and s[0] == '[') {
153 const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress;
154 if (close + 1 >= s.len or s[close + 1] != ':') return error.MalformedAddress;
155 return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) };
156 }
157 const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse return error.MalformedAddress;
158 // An unbracketed IPv6 literal carries colons of its own, and splitting
159 // on the last one would quietly take its final group as a port:
160 // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as
161 // host `fe80::1:4433` with the port left off. Brackets are how that
162 // ambiguity is spelled out, so without them it is refused rather than
163 // guessed at.
164 if (std.mem.indexOfScalar(u8, s[0..colon], ':') != null) return error.MalformedAddress;
165 return .{ .host = s[0..colon], .port = try parsePort(s[colon + 1 ..]) };
166 }
58 167
59 const sock_path = if (sock_arg) |s| 168 fn parsePort(s: []const u8) !u16 {
169 return std.fmt.parseInt(u16, s, 10) catch error.MalformedAddress;
170 }
171
172 fn parseBindAddr(s: []const u8) !std.net.Address {
173 const hp = try splitHostPort(s);
174 return std.net.Address.parseIp(hp.host, hp.port);
175 }
176
177 pub fn main() !u8 {
178 var gpa: std.heap.DebugAllocator(.{}) = .init;
179 defer _ = gpa.deinit();
180 const alloc = gpa.allocator();
181
182 const args = try std.process.argsAlloc(alloc);
183 defer std.process.argsFree(alloc, args);
184
185 const o = switch (parseArgs(args)) {
186 .err => |u| return usageExit(u),
187 .ok => |o| o,
188 };
189
190 const sock_path = if (o.sock) |s|
60 try alloc.dupe(u8, s) 191 try alloc.dupe(u8, s)
61 else 192 else
62 try defaultSockPath(alloc); 193 try defaultSockPath(alloc);
63 defer alloc.free(sock_path); 194 defer alloc.free(sock_path);
64 195
65 if (std.mem.eql(u8, args[1], "run")) { 196 switch (o.cmd) {
66 const shell_z: [:0]const u8 = if (shell_arg) |s| 197 .run => return run(alloc, o, sock_path),
67 try alloc.dupeZ(u8, s) 198 .dump => return dump(alloc, sock_path, o.vt),
68 else 199 .stats => return stats(alloc, sock_path),
69 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); 200 .proxy => return proxy.run(sock_path),
70 defer alloc.free(shell_z); 201 }
71 202 }
72 var srv = Server.init(alloc, .{ 203
73 .sock_path = sock_path, 204 fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
74 .shell = shell_z, 205 // Address and key are settled before anything binds: a mistyped address
75 .cols = cols, 206 // or an unreadable key must not first leave a session socket and a live
76 .rows = rows, 207 // shell behind. Same discipline as the key loader's own refusals.
77 }) catch |err| switch (err) { 208 var quic_bind: ?std.net.Address = null;
78 // All of these mean "that path is not ours to take", and all 209 var quic_key: quic.Key = undefined;
79 // are ordinary operator mistakes rather than daemon bugs: say 210 if (o.quic) |hostport| {
80 // so in one line and exit, no stack trace. 211 quic_bind = parseBindAddr(hostport) catch {
81 // 212 std.debug.print(
82 // AddressInUse is the same situation found one syscall later: 213 "muxd: --quic wants HOST:PORT with a literal address, got {s}\n",
83 // two daemons starting at once can both see an empty path and 214 .{hostport},
84 // both try to bind it. The loser has simply lost a dead heat, 215 );
85 // and telling it "a daemon is already running" is exactly 216 return 1;
86 // right — by the time it reads the message, one is. 217 };
87 error.DaemonAlreadyRunning, error.AddressInUse => { 218 quic_key = quic.Key.load(o.key.?) catch |err| switch (err) {
88 std.debug.print("muxd: a daemon is already running on {s}\n", .{sock_path}); 219 error.KeyFileMissing => {
220 std.debug.print("muxd: no such key file: {s}\n", .{o.key.?});
221 return 1;
222 },
223 error.KeyFilePermissive => {
224 std.debug.print(
225 "muxd: {s} is readable by group or other; chmod 600 it\n",
226 .{o.key.?},
227 );
89 return 1; 228 return 1;
90 }, 229 },
91 error.SockPathNotASocket => { 230 error.KeyFileMalformed => {
92 std.debug.print("muxd: {s} exists and is not a socket\n", .{sock_path}); 231 std.debug.print(
232 "muxd: {s} is not a key: want 32 raw bytes or 64 hex characters\n",
233 .{o.key.?},
234 );
93 return 1; 235 return 1;
94 }, 236 },
95 else => return err, 237 else => return err,
96 }; 238 };
97 defer srv.deinit();
98 @import("server").installSignalHandlers();
99 return try srv.run();
100 } 239 }
101 if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode); 240
102 if (std.mem.eql(u8, args[1], "stats")) return stats(alloc, sock_path); 241 // The UDP socket is bound BEFORE the session socket, so a port that is
103 if (std.mem.eql(u8, args[1], "proxy")) return proxy.run(sock_path); 242 // already taken costs nothing: no shell has been started and no socket
104 std.debug.print("{s}", .{usage}); 243 // left on disk. It is the same discipline as loading the key first, one
105 return 2; 244 // syscall further along.
245 var listener: ?*quic.Listener = null;
246 if (quic_bind) |addr| {
247 listener = quic.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms) catch |err| switch (err) {
248 // The QUIC edition of "a daemon is already running", refused for
249 // the same reason: the listener sets no SO_REUSEADDR, so rather
250 // than silently splitting a port's datagrams with the daemon
251 // already there, the second one says so and stops.
252 error.AddressInUse => {
253 std.debug.print(
254 "muxd: a daemon is already listening on udp {s}\n",
255 .{o.quic.?},
256 );
257 return 1;
258 },
259 else => {
260 std.debug.print(
261 "muxd: cannot listen on udp {s}: {s}\n",
262 .{ o.quic.?, @errorName(err) },
263 );
264 return 1;
265 },
266 };
267 }
268 // Registered before the server's, so it runs after it: a client slot
269 // backed by QUIC closes through the listener, and the server must finish
270 // tearing its slots down before the listener is freed.
271 defer if (listener) |l| l.deinit();
272
273 const shell_z: [:0]const u8 = if (o.shell) |s|
274 try alloc.dupeZ(u8, s)
275 else
276 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
277 defer alloc.free(shell_z);
278
279 var srv = Server.init(alloc, .{
280 .sock_path = sock_path,
281 .shell = shell_z,
282 .cols = o.cols,
283 .rows = o.rows,
284 }) catch |err| switch (err) {
285 // All of these mean "that path is not ours to take", and all
286 // are ordinary operator mistakes rather than daemon bugs: say
287 // so in one line and exit, no stack trace.
288 //
289 // AddressInUse is the same situation found one syscall later:
290 // two daemons starting at once can both see an empty path and
291 // both try to bind it. The loser has simply lost a dead heat,
292 // and telling it "a daemon is already running" is exactly
293 // right — by the time it reads the message, one is.
294 error.DaemonAlreadyRunning, error.AddressInUse => {
295 std.debug.print("muxd: a daemon is already running on {s}\n", .{sock_path});
296 return 1;
297 },
298 error.SockPathNotASocket => {
299 std.debug.print("muxd: {s} exists and is not a socket\n", .{sock_path});
300 return 1;
301 },
302 else => return err,
303 };
304
305 defer srv.deinit();
306
307 if (listener) |l| {
308 l.setHandler(srv.quicHandler());
309 srv.attachQuic(l);
310 }
311
312 @import("server").installSignalHandlers();
313 return try srv.run();
106 } 314 }
107 315
108 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 316 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
@@ -147,3 +355,129 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
147 } 355 }
148 return 1; 356 return 1;
149 } 357 }
358
359 // ---------------------------------------------------------------------------
360 // Tests. These run because `exe_mod` was added to build.zig's test loop in
361 // the same commit; before that, a test written here would have compiled and
362 // silently never executed (the hazard recorded in decisions.md, which cost
363 // mux_main.zig five invisible tests).
364 // ---------------------------------------------------------------------------
365
366 /// parseArgs takes what argsAlloc produces, so the tests must speak the same
367 /// type: a slice of sentinel-terminated strings.
368 fn parse(comptime argv: []const [:0]const u8) ParseResult {
369 return parseArgs(argv);
370 }
371
372 test "parseArgs: subcommands and their existing flags" {
373 const r = parse(&.{ "muxd", "run" });
374 try std.testing.expect(r == .ok);
375 try std.testing.expect(r.ok.cmd == .run);
376 try std.testing.expect(r.ok.sock == null);
377 try std.testing.expectEqual(@as(u16, 80), r.ok.cols);
378 try std.testing.expectEqual(@as(u16, 24), r.ok.rows);
379
380 const d = parse(&.{ "muxd", "dump", "--vt", "--sock", "/tmp/x.sock" });
381 try std.testing.expect(d.ok.cmd == .dump);
382 try std.testing.expect(d.ok.vt);
383 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?);
384
385 const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" });
386 try std.testing.expectEqual(@as(u16, 120), g.ok.cols);
387 try std.testing.expectEqual(@as(u16, 40), g.ok.rows);
388 try std.testing.expectEqualStrings("/bin/dash", g.ok.shell.?);
389
390 try std.testing.expect(parse(&.{"muxd"}).err == .no_command);
391 try std.testing.expect(parse(&.{ "muxd", "wat" }).err == .unknown_command);
392 try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg);
393 }
394
395 test "parseArgs: --quic and --key are both or neither" {
396 const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" });
397 try std.testing.expect(both == .ok);
398 try std.testing.expectEqualStrings("0.0.0.0:4433", both.ok.quic.?);
399 try std.testing.expectEqualStrings("/k", both.ok.key.?);
400
401 // Naming one is not a request to guess the other: there is no
402 // unauthenticated listener and no default key path.
403 try std.testing.expect(parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" }).err == .quic_without_key);
404 try std.testing.expect(parse(&.{ "muxd", "run", "--key", "/k" }).err == .key_without_quic);
405
406 // Neither is the ordinary case and must stay silent.
407 const neither = parse(&.{ "muxd", "run" });
408 try std.testing.expect(neither.ok.quic == null);
409 try std.testing.expect(neither.ok.key == null);
410 }
411
412 test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" {
413 const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" });
414 try std.testing.expectEqual(default_quic_idle_ms, dflt.ok.quic_idle_ms);
415
416 const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" });
417 try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms);
418
419 // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone
420 // typing a timeout of zero is asking for, so it is refused rather than
421 // silently inverted.
422 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_number);
423 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_number);
424 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_number);
425 // Wider than u32: refused at the parse rather than overflowing where it
426 // is multiplied out to nanoseconds.
427 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_number);
428 // The idle flag alone does not turn QUIC on, and must not smuggle the
429 // both-or-neither rule past the check.
430 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok);
431
432 // Same treatment for the numbers that were already here.
433 try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_number);
434 try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_number);
435 }
436
437 test "parseArgs: a value-taking flag at the end of argv names itself" {
438 // This used to report "unknown argument: --quic", which blames the flag
439 // rather than the missing value.
440 inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms" }) |flag| {
441 const r = parse(&.{ "muxd", "run", flag });
442 try std.testing.expect(r.err == .missing_value);
443 try std.testing.expectEqualStrings(flag, r.err.missing_value);
444 }
445 }
446
447 test "splitHostPort: literal addresses, bracketed and not" {
448 const v4 = try splitHostPort("127.0.0.1:4433");
449 try std.testing.expectEqualStrings("127.0.0.1", v4.host);
450 try std.testing.expectEqual(@as(u16, 4433), v4.port);
451
452 const v6 = try splitHostPort("[::1]:4433");
453 try std.testing.expectEqualStrings("::1", v6.host);
454 try std.testing.expectEqual(@as(u16, 4433), v6.port);
455
456 const any6 = try splitHostPort("[::]:1");
457 try std.testing.expectEqualStrings("::", any6.host);
458 try std.testing.expectEqual(@as(u16, 1), any6.port);
459
460 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1"));
461 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:"));
462 try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999"));
463 try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433"));
464 try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]"));
465
466 // An IPv6 literal without brackets is ambiguous about where the address
467 // stops, so it is refused instead of being read either way.
468 try std.testing.expectError(error.MalformedAddress, splitHostPort("::1:4433"));
469 try std.testing.expectError(error.MalformedAddress, splitHostPort("fe80::1:4433"));
470 }
471
472 test "parseBindAddr: a hostname is refused, not resolved" {
473 const a = try parseBindAddr("127.0.0.1:4433");
474 try std.testing.expectEqual(@as(u16, 4433), a.getPort());
475
476 const six = try parseBindAddr("[::1]:4433");
477 try std.testing.expectEqual(@as(u16, 4433), six.getPort());
478 try std.testing.expect(six.any.family == std.posix.AF.INET6);
479
480 // No DNS at bind time, deliberately: this is the address to bind, and a
481 // name resolving to several is a question rather than an answer.
482 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433")));
483 }
src/quic_server.zig
Old New
@@ -207,10 +207,32 @@ pub const Handler = struct {
207 onOpen: *const fn (ctx: *anyopaque, id: u64) void, 207 onOpen: *const fn (ctx: *anyopaque, id: u64) void,
208 /// Stream bytes arrived. Arbitrary chunking: the owner reassembles. 208 /// Stream bytes arrived. Arbitrary chunking: the owner reassembles.
209 onData: *const fn (ctx: *anyopaque, id: u64, bytes: []const u8) void, 209 onData: *const fn (ctx: *anyopaque, id: u64, bytes: []const u8) void,
210 /// A connection the LISTENER gave up on: an idle timeout, a protocol
211 /// error, a peer that went away.
212 ///
213 /// These two do not pair, and an owner that assumes they do will be
214 /// wrong in both directions. A handshake that never completes — a wrong
215 /// key, a client that vanishes mid-flight — produces neither: the
216 /// connection is torn down having never been announced, because there
217 /// was never anything to announce. And a close the owner itself asked
218 /// for via `closeConn` produces no callback either. `onClose` means
219 /// "this ended without you asking", nothing more.
210 onClose: *const fn (ctx: *anyopaque, id: u64) void, 220 onClose: *const fn (ctx: *anyopaque, id: u64) void,
211 }; 221 };
212 222
223 /// Connections the listener will hold at once. Deliberately larger than the
224 /// daemon's `max_clients`: a connection exists from the moment its handshake
225 /// completes, and only then asks for a client slot — so the extra room is
226 /// headroom for handshakes in flight, not for sessions. A connection that
227 /// finds no slot is answered and closed (see the daemon's onOpen), which is
228 /// why the two numbers do not need to agree.
213 const max_conns = 16; 229 const max_conns = 16;
230
231 /// Source Connection IDs cached per connection. ngtcp2 caps its own pool at
232 /// NGTCP2_MAX_SCID_POOL_SIZE (8), so this is headroom; and because the
233 /// primary CID is matched separately, a cache that ever did fill would
234 /// degrade to the old primary-only behaviour rather than misroute.
235 const max_cids = 16;
214 const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting 236 const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting
215 237
216 /// wolfSSL's PSK callbacks carry no user pointer, so the key has to be 238 /// wolfSSL's PSK callbacks carry no user pointer, so the key has to be
@@ -244,6 +266,93 @@ const psk_identity: [*:0]const u8 = "mux";
244 const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256"; 266 const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256";
245 const alpn = "\x03mux"; 267 const alpn = "\x03mux";
246 268
269 /// How many outbound bytes one connection may hold. Sized to the stream
270 /// window the peer advertises, because holding much more than the peer will
271 /// let us send buys nothing: past this the daemon's own `pending_cap` is the
272 /// right place for the backlog to sit and be judged.
273 const egress_cap = 256 * 1024;
274
275 /// Outbound stream bytes, in a ring that never moves a byte once written.
276 ///
277 /// A ring rather than a growable buffer, because ngtcp2 does NOT copy stream
278 /// payload: `ngtcp2_conn_writev_stream` stores the *vector* it is handed —
279 /// `ngtcp2_vec_copy` is a memcpy of base+len, not of the bytes — in its
280 /// retransmission queue, and re-reads those bytes if the packet is lost. A
281 /// buffer that reallocates on append, or that clears once the last byte has
282 /// been handed over, therefore leaves ngtcp2 holding a freed or recycled
283 /// pointer, and it dies inside `ngtcp2_pkt_encode_stream_frame`. It did:
284 /// rarely, only under loss, and only in the one test big enough to overflow
285 /// a socket buffer — which is the worst shape a bug can have.
286 ///
287 /// So the invariant, and everything here exists to hold it: **a byte handed
288 /// to ngtcp2 does not move or get overwritten until the peer acknowledges
289 /// it.** `head` advances only from `acked_stream_data_offset`; a write can
290 /// only land in the free space that leaves. Being fixed in size is the other
291 /// half of the point — a full ring is the backpressure signal.
292 const Egress = struct {
293 buf: []u8,
294 /// The oldest byte the peer has not acknowledged.
295 head: usize = 0,
296 /// Bytes from `head` still owed: handed to ngtcp2 and unacknowledged,
297 /// plus not yet handed over.
298 held: usize = 0,
299 /// The tail of `held` that ngtcp2 has not taken yet.
300 unsent: usize = 0,
301
302 fn deinit(self: *Egress, alloc: std.mem.Allocator) void {
303 alloc.free(self.buf);
304 self.* = .{ .buf = &.{} };
305 }
306
307 fn freeSpace(self: *const Egress) usize {
308 return self.buf.len - self.held;
309 }
310
311 /// Take what fits and report how much that was. A short return is not an
312 /// error, it is the whole mechanism: the caller keeps the remainder and
313 /// is thereby the one holding — and bounding — the backlog.
314 fn push(self: *Egress, bytes: []const u8) usize {
315 const n = @min(bytes.len, self.freeSpace());
316 if (n == 0) return 0;
317 const start = (self.head + self.held) % self.buf.len;
318 const first = @min(n, self.buf.len - start);
319 @memcpy(self.buf[start..][0..first], bytes[0..first]);
320 if (first < n) @memcpy(self.buf[0 .. n - first], bytes[first..n]);
321 self.held += n;
322 self.unsent += n;
323 return n;
324 }
325
326 /// The unsent region as up to two vectors — two when it wraps, which is
327 /// the price of never moving a byte, and ngtcp2 takes a vector array
328 /// precisely so that price is payable.
329 fn vecs(self: *const Egress, out: *[2]c.ngtcp2_vec) usize {
330 if (self.unsent == 0) return 0;
331 const start = (self.head + (self.held - self.unsent)) % self.buf.len;
332 const first = @min(self.unsent, self.buf.len - start);
333 out[0] = .{ .base = self.buf.ptr + start, .len = first };
334 if (first == self.unsent) return 1;
335 out[1] = .{ .base = self.buf.ptr, .len = self.unsent - first };
336 return 2;
337 }
338
339 /// ngtcp2 took `n` bytes off the unsent region. They stay exactly where
340 /// they are — it now has pointers to them.
341 fn took(self: *Egress, n: usize) void {
342 self.unsent -= @min(n, self.unsent);
343 }
344
345 /// The peer acknowledged `n` more bytes. ngtcp2 documents this callback
346 /// as arriving "sequentially in increasing order of offset without any
347 /// overlap", so a running count IS the acknowledged prefix, and this is
348 /// the only thing that ever frees space.
349 fn ack(self: *Egress, n: usize) void {
350 const taken = @min(n, self.held - self.unsent);
351 self.head = (self.head + taken) % self.buf.len;
352 self.held -= taken;
353 }
354 };
355
247 /// One authenticated peer: an ngtcp2 connection, its TLS object, and the 356 /// One authenticated peer: an ngtcp2 connection, its TLS object, and the
248 /// single bidirectional stream that carries everything. 357 /// single bidirectional stream that carries everything.
249 const Conn = struct { 358 const Conn = struct {
@@ -257,13 +366,43 @@ const Conn = struct {
257 local_storage: std.posix.sockaddr.storage = undefined, 366 local_storage: std.posix.sockaddr.storage = undefined,
258 local_len: std.posix.socklen_t = 0, 367 local_len: std.posix.socklen_t = 0,
259 scid: c.ngtcp2_cid = undefined, 368 scid: c.ngtcp2_cid = undefined,
369 /// Every Connection ID this endpoint has advertised and not retired.
370 /// A peer is entitled to address us by any of them — that is what makes
371 /// connection migration work — so matching only `scid` meant a client
372 /// that switched CID had its packets fall through to `accept`, where
373 /// they were dropped as noise.
374 cids: [max_cids]c.ngtcp2_cid = undefined,
375 ncids: usize = 0,
376 cids_dirty: bool = true,
260 stream_id: i64 = -1, 377 stream_id: i64 = -1,
261 /// Bytes the owner asked us to send that ngtcp2 has not taken yet. 378 /// Bytes owed to this peer. See Egress: they do not move until acked.
262 out: std.ArrayList(u8) = .empty, 379 out: Egress,
263 out_sent: usize = 0,
264 opened: bool = false, 380 opened: bool = false,
265 381
266 fn deinit(self: *Conn, alloc: std.mem.Allocator) void { 382 /// Re-read the advertised CIDs from ngtcp2. Lazy, because the answer
383 /// only changes when one is issued or retired and both of those tell us.
384 fn refreshCids(self: *Conn) void {
385 self.cids_dirty = false;
386 self.ncids = 0;
387 const conn = self.conn orelse return;
388 const n = c.ngtcp2_conn_get_scid2(conn, null);
389 if (n == 0 or n > max_cids) return;
390 self.ncids = c.ngtcp2_conn_get_scid2(conn, &self.cids);
391 }
392
393 /// Is this connection addressed by `dcid`? The primary is checked first
394 /// and without the cache, so this can only ever be more permissive than
395 /// the CID the connection was created with — never less.
396 fn matches(self: *Conn, dcid: []const u8) bool {
397 if (cidEql(&self.scid, dcid)) return true;
398 if (self.cids_dirty) self.refreshCids();
399 for (self.cids[0..self.ncids]) |*cid| {
400 if (cidEql(cid, dcid)) return true;
401 }
402 return false;
403 }
404
405 pub fn deinit(self: *Conn, alloc: std.mem.Allocator) void {
267 self.out.deinit(alloc); 406 self.out.deinit(alloc);
268 if (self.conn) |cn| c.ngtcp2_conn_del(cn); 407 if (self.conn) |cn| c.ngtcp2_conn_del(cn);
269 if (self.ssl) |s| c.wolfSSL_free(s); 408 if (self.ssl) |s| c.wolfSSL_free(s);
@@ -281,6 +420,45 @@ fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv
281 std.crypto.random.bytes(dest[0..destlen]); 420 std.crypto.random.bytes(dest[0..destlen]);
282 } 421 }
283 422
423 /// The handler a listener carries between `bind` and `setHandler`: it
424 /// exists so that window has no null to check on every packet.
425 fn ignoreOpen(_: *anyopaque, _: u64) void {}
426 fn ignoreData(_: *anyopaque, _: u64, _: []const u8) void {}
427 fn ignoreClose(_: *anyopaque, _: u64) void {}
428
429 fn cidEql(cid: *const c.ngtcp2_cid, bytes: []const u8) bool {
430 if (cid.datalen != bytes.len) return false;
431 return std.mem.eql(u8, cid.data[0..cid.datalen], bytes);
432 }
433
434 /// The server's variant: mints the CID exactly as the shared callback does,
435 /// and marks the connection's cache stale. Separate from `getNewCidCb`
436 /// because the test client shares that one and its user_data is not a Conn.
437 fn serverGetNewCidCb(
438 conn: ?*c.ngtcp2_conn,
439 cid: [*c]c.ngtcp2_cid,
440 token: [*c]c.ngtcp2_stateless_reset_token,
441 cidlen: usize,
442 user_data: ?*anyopaque,
443 ) callconv(.c) c_int {
444 const rv = getNewCidCb(conn, cid, token, cidlen, user_data);
445 const cn: *Conn = @ptrCast(@alignCast(user_data.?));
446 cn.cids_dirty = true;
447 return rv;
448 }
449
450 /// A CID was retired: the cache must stop matching it, or this listener
451 /// would keep answering to a name the peer has been told to forget.
452 fn serverRemoveCidCb(
453 _: ?*c.ngtcp2_conn,
454 _: [*c]const c.ngtcp2_cid,
455 user_data: ?*anyopaque,
456 ) callconv(.c) c_int {
457 const cn: *Conn = @ptrCast(@alignCast(user_data.?));
458 cn.cids_dirty = true;
459 return 0;
460 }
461
284 fn getNewCidCb( 462 fn getNewCidCb(
285 _: ?*c.ngtcp2_conn, 463 _: ?*c.ngtcp2_conn,
286 cid: [*c]c.ngtcp2_cid, 464 cid: [*c]c.ngtcp2_cid,
@@ -301,6 +479,22 @@ fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, user_data: ?*anyopaque) callconv(.c)
301 return 0; 479 return 0;
302 } 480 }
303 481
482 /// The peer acknowledged stream bytes, so the space they occupy can be
483 /// reused. This callback is the ONLY thing that frees egress space, and
484 /// registering it is what makes the buffer's no-move invariant affordable.
485 fn ackedStreamDataCb(
486 _: ?*c.ngtcp2_conn,
487 _: i64,
488 _: u64,
489 datalen: u64,
490 user_data: ?*anyopaque,
491 _: ?*anyopaque,
492 ) callconv(.c) c_int {
493 const cn: *Conn = @ptrCast(@alignCast(user_data.?));
494 cn.out.ack(@intCast(datalen));
495 return 0;
496 }
497
304 fn streamOpenCb(_: ?*c.ngtcp2_conn, stream_id: i64, user_data: ?*anyopaque) callconv(.c) c_int { 498 fn streamOpenCb(_: ?*c.ngtcp2_conn, stream_id: i64, user_data: ?*anyopaque) callconv(.c) c_int {
305 const cn: *Conn = @ptrCast(@alignCast(user_data.?)); 499 const cn: *Conn = @ptrCast(@alignCast(user_data.?));
306 // One stream per connection: the first the client opens is the session. 500 // One stream per connection: the first the client opens is the session.
@@ -329,6 +523,14 @@ fn recvStreamDataCb(
329 _ = c.ngtcp2_conn_extend_max_stream_offset(conn, stream_id, datalen); 523 _ = c.ngtcp2_conn_extend_max_stream_offset(conn, stream_id, datalen);
330 _ = c.ngtcp2_conn_extend_max_offset(conn, datalen); 524 _ = c.ngtcp2_conn_extend_max_offset(conn, datalen);
331 525
526 // Note the order: the window is extended BEFORE the owner has done
527 // anything with these bytes, so the credit is granted against a consume
528 // that has not happened yet. That is the right trade here and not an
529 // oversight — the owner's `onData` is synchronous and bounded (it
530 // appends to a queue the daemon's own cap governs), so there is no
531 // second buffer to overflow by being generous. It would stop being the
532 // right trade the moment an owner could hold bytes indefinitely, which
533 // is exactly what the egress ring now refuses to let it do.
332 if (datalen > 0) { 534 if (datalen > 0) {
333 cn.listener.handler.onData(cn.listener.handler.ctx, cn.id, data[0..datalen]); 535 cn.listener.handler.onData(cn.listener.handler.ctx, cn.id, data[0..datalen]);
334 } 536 }
@@ -348,6 +550,32 @@ pub const Listener = struct {
348 retry_secret: [32]u8, 550 retry_secret: [32]u8,
349 idle_ms: u64, 551 idle_ms: u64,
350 552
553 /// Bind the socket and stand up TLS: everything that can fail for
554 /// reasons outside this process. Split from the handler because the
555 /// daemon that will own these connections does not exist until its own
556 /// socket is bound, and this one has to be bound FIRST — otherwise a
557 /// refused UDP port has already cost a session socket and a live shell.
558 ///
559 /// A listener returned from here drops anything that arrives until
560 /// `setHandler` is called. Nothing polls it before then.
561 pub fn bind(
562 alloc: std.mem.Allocator,
563 bind_addr: std.net.Address,
564 key: Key,
565 idle_ms: u64,
566 ) !*Listener {
567 return init(alloc, bind_addr, key, .{
568 .ctx = undefined,
569 .onOpen = ignoreOpen,
570 .onData = ignoreData,
571 .onClose = ignoreClose,
572 }, idle_ms);
573 }
574
575 pub fn setHandler(self: *Listener, h: Handler) void {
576 self.handler = h;
577 }
578
351 pub fn init( 579 pub fn init(
352 alloc: std.mem.Allocator, 580 alloc: std.mem.Allocator,
353 bind_addr: std.net.Address, 581 bind_addr: std.net.Address,
@@ -363,7 +591,14 @@ pub const Listener = struct {
363 0, 591 0,
364 ); 592 );
365 errdefer std.posix.close(fd); 593 errdefer std.posix.close(fd);
366 try std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); 594 // Deliberately NO SO_REUSEADDR. On UDP it lets a second daemon bind
595 // the same address, and the kernel then hands each datagram to one
596 // of them: two sessions silently splitting one port, with packets
597 // going to whichever process the kernel picked. The unix socket has
598 // a whole story for "a daemon is already running" precisely because
599 // taking over another daemon's endpoint by accident is unacceptable;
600 // this is that story's QUIC edition, and the answer is the same —
601 // fail the bind, loudly, and let the operator decide.
367 try std.posix.bind(fd, &bind_addr.any, bind_addr.getOsSockLen()); 602 try std.posix.bind(fd, &bind_addr.any, bind_addr.getOsSockLen());
368 603
369 if (c.wolfSSL_Init() != c.WOLFSSL_SUCCESS) return error.TlsInit; 604 if (c.wolfSSL_Init() != c.WOLFSSL_SUCCESS) return error.TlsInit;
@@ -420,14 +655,57 @@ pub const Listener = struct {
420 return null; 655 return null;
421 } 656 }
422 657
423 /// Queue bytes for a peer's stream. Egress is drained here and again on 658 /// Queue what fits of `bytes` for a peer's stream and report how many
424 /// every event, so a caller never has to think about flushing. 659 /// were taken. Egress is drained here and again on every event, so a
425 pub fn send(self: *Listener, id: u64, bytes: []const u8) !void { 660 /// caller never has to think about flushing.
661 ///
662 /// A short return is the backpressure signal, and it has to exist: while
663 /// this accepted everything unconditionally, a peer that stopped reading
664 /// grew an unbounded buffer down here, where nothing watches it, instead
665 /// of tripping the daemon's `pending_cap` up where something does. The
666 /// contract now matches the socket sink's — take what you can, tell the
667 /// truth about how much — so one rule bounds both kinds of client.
668 pub fn send(self: *Listener, id: u64, bytes: []const u8) !usize {
426 const cn = self.find(id) orelse return error.NoSuchConn; 669 const cn = self.find(id) orelse return error.NoSuchConn;
427 try cn.out.appendSlice(self.alloc, bytes); 670 const n = cn.out.push(bytes);
671 self.drain(cn);
672 return n;
673 }
674
675 /// Bytes accepted from the owner that the peer has not acknowledged.
676 /// Zero means this connection owes nothing — the only honest answer to
677 /// "has it all gone out", since a QUIC send is not done when the syscall
678 /// returns but when the ack arrives.
679 pub fn pendingBytes(self: *Listener, id: u64) usize {
680 const cn = self.find(id) orelse return 0;
681 return cn.out.held;
682 }
683
684 /// Push a connection's egress along without an event to hang it off —
685 /// what the daemon calls when it is draining on the way out, or when
686 /// acks have just freed room that queued bytes are waiting for.
687 pub fn kick(self: *Listener, id: u64) void {
688 const cn = self.find(id) orelse return;
428 self.drain(cn); 689 self.drain(cn);
429 } 690 }
430 691
692 /// Close ONE connection and nothing else — never the socket it shares.
693 ///
694 /// The distinction is the whole reason a client slot stopped being a
695 /// descriptor: every QUIC peer on this daemon is multiplexed over one
696 /// UDP socket, so closing "the client's transport" because one session
697 /// ended would take down every other session with it.
698 ///
699 /// Two things this deliberately does NOT do, both of which callers have
700 /// to know. It does not invoke `onClose`: a close the owner asked for
701 /// needs no callback telling the owner what it just did, and calling
702 /// back into a handler mid-teardown is how re-entrancy bugs start. Only
703 /// `kill` — the listener deciding a connection is finished — calls back.
704 /// And it does not send CONNECTION_CLOSE: the peer finds out when its
705 /// idle timer expires. That is a real cost (a client learns of a
706 /// deliberate close no faster than of a crash) and it is accepted for
707 /// now rather than unnoticed; a graceful close belongs with the client
708 /// transport, which is the side that would act on it.
431 pub fn closeConn(self: *Listener, id: u64) void { 709 pub fn closeConn(self: *Listener, id: u64) void {
432 for (&self.conns) |*slot| { 710 for (&self.conns) |*slot| {
433 if (slot.*) |cn| { 711 if (slot.*) |cn| {
@@ -486,12 +764,17 @@ pub const Listener = struct {
486 } else return; // full: drop, the peer will retry 764 } else return; // full: drop, the peer will retry
487 765
488 const cn = self.alloc.create(Conn) catch return; 766 const cn = self.alloc.create(Conn) catch return;
489 cn.* = .{ .id = self.next_id, .listener = self }; 767 const ring = self.alloc.alloc(u8, egress_cap) catch {
768 self.alloc.destroy(cn);
769 return;
770 };
771 cn.* = .{ .id = self.next_id, .listener = self, .out = .{ .buf = ring } };
490 self.next_id += 1; 772 self.next_id += 1;
491 @memcpy(std.mem.asBytes(&cn.remote)[0..from_len], std.mem.asBytes(from)[0..from_len]); 773 @memcpy(std.mem.asBytes(&cn.remote)[0..from_len], std.mem.asBytes(from)[0..from_len]);
492 cn.remote_len = from_len; 774 cn.remote_len = from_len;
493 cn.local_len = @sizeOf(std.posix.sockaddr.storage); 775 cn.local_len = @sizeOf(std.posix.sockaddr.storage);
494 std.posix.getsockname(self.fd, @ptrCast(&cn.local_storage), &cn.local_len) catch { 776 std.posix.getsockname(self.fd, @ptrCast(&cn.local_storage), &cn.local_len) catch {
777 cn.out.deinit(self.alloc);
495 self.alloc.destroy(cn); 778 self.alloc.destroy(cn);
496 return; 779 return;
497 }; 780 };
@@ -504,6 +787,7 @@ pub const Listener = struct {
504 cn.scid = hd.dcid; 787 cn.scid = hd.dcid;
505 788
506 const ssl = c.wolfSSL_new(self.ssl_ctx) orelse { 789 const ssl = c.wolfSSL_new(self.ssl_ctx) orelse {
790 cn.out.deinit(self.alloc);
507 self.alloc.destroy(cn); 791 self.alloc.destroy(cn);
508 return; 792 return;
509 }; 793 };
@@ -523,10 +807,12 @@ pub const Listener = struct {
523 cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb; 807 cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb;
524 cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb; 808 cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb;
525 cbs.rand = randCb; 809 cbs.rand = randCb;
526 cbs.get_new_connection_id2 = getNewCidCb; 810 cbs.get_new_connection_id2 = serverGetNewCidCb;
811 cbs.remove_connection_id = serverRemoveCidCb;
527 cbs.handshake_completed = handshakeCompletedCb; 812 cbs.handshake_completed = handshakeCompletedCb;
528 cbs.stream_open = streamOpenCb; 813 cbs.stream_open = streamOpenCb;
529 cbs.recv_stream_data = recvStreamDataCb; 814 cbs.recv_stream_data = recvStreamDataCb;
815 cbs.acked_stream_data_offset = ackedStreamDataCb;
530 816
531 var settings: c.ngtcp2_settings = undefined; 817 var settings: c.ngtcp2_settings = undefined;
532 c.ngtcp2_settings_default_versioned(c.NGTCP2_SETTINGS_VERSION, &settings); 818 c.ngtcp2_settings_default_versioned(c.NGTCP2_SETTINGS_VERSION, &settings);
@@ -570,11 +856,20 @@ pub const Listener = struct {
570 cn, 856 cn,
571 ) != 0) { 857 ) != 0) {
572 c.wolfSSL_free(ssl); 858 c.wolfSSL_free(ssl);
859 cn.out.deinit(self.alloc);
573 self.alloc.destroy(cn); 860 self.alloc.destroy(cn);
574 return; 861 return;
575 } 862 }
576 cn.conn = conn; 863 cn.conn = conn;
577 c.ngtcp2_conn_set_tls_native_handle(conn, ssl); 864 c.ngtcp2_conn_set_tls_native_handle(conn, ssl);
865 // Without this, a terminal nobody is typing into is
866 // indistinguishable from a peer that has gone away, and the
867 // connection is dropped after idle_ms of quiet — which for a
868 // default of 15s means a session dies while its owner reads the
869 // screen. The keep-alive PING is ack-eliciting, so its ACK restarts
870 // the idle timer at both ends; a peer that has genuinely vanished
871 // answers nothing and still times out on schedule.
872 c.ngtcp2_conn_set_keep_alive_timeout(conn, keepAliveNs(self.idle_ms));
578 slot.* = cn; 873 slot.* = cn;
579 874
580 self.feed(slot, pkt, from, from_len); 875 self.feed(slot, pkt, from, from_len);
@@ -687,11 +982,16 @@ pub const Listener = struct {
687 const rv = c.ngtcp2_pkt_decode_version_cid(&vc, pkt.ptr, pkt.len, 8); 982 const rv = c.ngtcp2_pkt_decode_version_cid(&vc, pkt.ptr, pkt.len, 8);
688 if (rv != 0) return; 983 if (rv != 0) return;
689 984
690 // Known connection? Match on the destination CID we handed out. 985 // Known connection? Match on ANY Connection ID this endpoint has
986 // advertised, not merely the one the connection was created with.
987 // A client that migrates — a new network, a NAT rebind, the exact
988 // case QUIC exists to survive — switches to another CID we gave it
989 // (RFC 9000 section 9.5). Matching only the first one sent those
990 // packets to `accept`, which has no token for them and drops them:
991 // the migration presents as a dead connection.
691 for (&self.conns) |*slot| { 992 for (&self.conns) |*slot| {
692 const cn = slot.* orelse continue; 993 const cn = slot.* orelse continue;
693 if (cn.scid.datalen != vc.dcidlen) continue; 994 if (!cn.matches(vc.dcid[0..vc.dcidlen])) continue;
694 if (!std.mem.eql(u8, cn.scid.data[0..cn.scid.datalen], vc.dcid[0..vc.dcidlen])) continue;
695 self.feed(slot, pkt, from, from_len); 995 self.feed(slot, pkt, from, from_len);
696 return; 996 return;
697 } 997 }
@@ -723,25 +1023,24 @@ pub const Listener = struct {
723 1023
724 /// Drive egress until ngtcp2 has nothing more to send. Runs after every 1024 /// Drive egress until ngtcp2 has nothing more to send. Runs after every
725 /// event, which is the contract the library expects. 1025 /// event, which is the contract the library expects.
726 fn drain(self: *Listener, cn: *Conn) void { 1026 pub fn drain(self: *Listener, cn: *Conn) void {
727 const conn = cn.conn orelse return; 1027 const conn = cn.conn orelse return;
728 var buf: [max_udp]u8 = undefined; 1028 var buf: [max_udp]u8 = undefined;
1029 // Set when the peer's stream window is full. Everything that is not
1030 // stream data still has to leave.
1031 var stream_blocked = false;
729 while (true) { 1032 while (true) {
730 var ps: c.ngtcp2_path_storage = undefined; 1033 var ps: c.ngtcp2_path_storage = undefined;
731 c.ngtcp2_path_storage_zero(&ps); 1034 c.ngtcp2_path_storage_zero(&ps);
732 var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 }; 1035 var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 };
733 var wrote: c.ngtcp2_ssize = 0; 1036 var wrote: c.ngtcp2_ssize = 0;
734 1037
735 var vec: c.ngtcp2_vec = undefined; 1038 var vecs: [2]c.ngtcp2_vec = undefined;
736 var vcnt: usize = 0; 1039 var vcnt: usize = 0;
737 var sid: i64 = -1; 1040 var sid: i64 = -1;
738 if (cn.stream_id != -1 and cn.out_sent < cn.out.items.len) { 1041 if (!stream_blocked and cn.stream_id != -1) {
739 vec = .{ 1042 vcnt = cn.out.vecs(&vecs);
740 .base = cn.out.items.ptr + cn.out_sent, 1043 if (vcnt > 0) sid = cn.stream_id;
741 .len = cn.out.items.len - cn.out_sent,
742 };
743 vcnt = 1;
744 sid = cn.stream_id;
745 } 1044 }
746 1045
747 const n = c.ngtcp2_conn_writev_stream_versioned( 1046 const n = c.ngtcp2_conn_writev_stream_versioned(
@@ -754,12 +1053,22 @@ pub const Listener = struct {
754 &wrote, 1053 &wrote,
755 0, 1054 0,
756 sid, 1055 sid,
757 if (vcnt > 0) &vec else null, 1056 if (vcnt > 0) &vecs else null,
758 vcnt, 1057 vcnt,
759 timestampNs(), 1058 timestampNs(),
760 ); 1059 );
1060 if (n == c.NGTCP2_ERR_STREAM_DATA_BLOCKED or n == c.NGTCP2_ERR_STREAM_SHUT_WR) {
1061 // A documented return, not a failure: the peer has no window
1062 // left for this stream. Treating it as fatal abandoned the
1063 // whole egress loop — including the ACKs and the flow-control
1064 // updates that are how the peer's window reopens, which turns
1065 // a moment of backpressure into a stall that never resolves.
1066 // Retry the same iteration carrying no stream data.
1067 stream_blocked = true;
1068 continue;
1069 }
761 if (n < 0) return; 1070 if (n < 0) return;
762 if (wrote > 0) cn.out_sent += @intCast(wrote); 1071 if (wrote > 0) cn.out.took(@intCast(wrote));
763 if (n == 0) break; 1072 if (n == 0) break;
764 1073
765 _ = std.posix.sendto( 1074 _ = std.posix.sendto(
@@ -770,13 +1079,6 @@ pub const Listener = struct {
770 cn.remote_len, 1079 cn.remote_len,
771 ) catch break; 1080 ) catch break;
772 } 1081 }
773
774 // Reclaim once the queue is fully handed over, so a long session
775 // does not grow an unbounded outbound buffer.
776 if (cn.out_sent > 0 and cn.out_sent == cn.out.items.len) {
777 cn.out.clearRetainingCapacity();
778 cn.out_sent = 0;
779 }
780 } 1082 }
781 1083
782 fn kill(self: *Listener, slot: *?*Conn) void { 1084 fn kill(self: *Listener, slot: *?*Conn) void {
@@ -790,6 +1092,14 @@ pub const Listener = struct {
790 } 1092 }
791 }; 1093 };
792 1094
1095 /// A third of the idle timeout, so two keepalives can go unanswered before
1096 /// the connection is called dead. Never zero: ngtcp2 reads a zero timeout as
1097 /// "disabled", exactly as it reads UINT64_MAX, so a small idle_ms rounding
1098 /// down would silently restore the behaviour the keepalive exists to prevent.
1099 fn keepAliveNs(idle_ms: u64) u64 {
1100 return @max(1, idle_ms / 3) * 1_000_000;
1101 }
1102
793 fn timestampNs() u64 { 1103 fn timestampNs() u64 {
794 const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0; 1104 const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0;
795 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); 1105 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
@@ -823,7 +1133,10 @@ fn pskClientCb(
823 return key_len; 1133 return key_len;
824 } 1134 }
825 1135
826 const TestClient = struct { 1136 /// Test scaffolding, exported so the daemon's own tests can drive a real
1137 /// QUIC peer. Not a preview of Task 3's client transport: no reconnect, no
1138 /// resumption, and a fixed receive buffer.
1139 pub const TestClient = struct {
827 fd: std.posix.fd_t, 1140 fd: std.posix.fd_t,
828 ssl_ctx: ?*c.WOLFSSL_CTX, 1141 ssl_ctx: ?*c.WOLFSSL_CTX,
829 ssl: ?*c.WOLFSSL, 1142 ssl: ?*c.WOLFSSL,
@@ -836,8 +1149,20 @@ const TestClient = struct {
836 stream_id: i64 = -1, 1149 stream_id: i64 = -1,
837 handshake_done: bool = false, 1150 handshake_done: bool = false,
838 echoed: usize = 0, 1151 echoed: usize = 0,
1152 /// When set, every arriving byte is compared against this at the offset
1153 /// it arrives at. A transport that echoes the right COUNT of the wrong
1154 /// bytes satisfies a length assertion and fails this one.
1155 verify: ?[]const u8 = null,
1156 mismatch: bool = false,
1157 /// Whether to give the peer more stream window as data is consumed.
1158 /// Turning it off is how a test reaches the blocked-stream path, which
1159 /// is a documented return value rather than a failure.
1160 extend: bool = true,
839 out: []const u8 = &.{}, 1161 out: []const u8 = &.{},
840 out_sent: usize = 0, 1162 out_sent: usize = 0,
1163 /// Everything the peer sent, so a test can walk frames out of it.
1164 recv_buf: [128 * 1024]u8 = undefined,
1165 recv_len: usize = 0,
841 1166
842 fn getConn(ref: [*c]c.ngtcp2_crypto_conn_ref) callconv(.c) ?*c.ngtcp2_conn { 1167 fn getConn(ref: [*c]c.ngtcp2_crypto_conn_ref) callconv(.c) ?*c.ngtcp2_conn {
843 const s: *TestClient = @ptrCast(@alignCast(ref.*.user_data)); 1168 const s: *TestClient = @ptrCast(@alignCast(ref.*.user_data));
@@ -864,7 +1189,7 @@ const TestClient = struct {
864 _: u32, 1189 _: u32,
865 stream_id: i64, 1190 stream_id: i64,
866 _: u64, 1191 _: u64,
867 _: [*c]const u8, 1192 data: [*c]const u8,
868 datalen: usize, 1193 datalen: usize,
869 ud: ?*anyopaque, 1194 ud: ?*anyopaque,
870 _: ?*anyopaque, 1195 _: ?*anyopaque,
@@ -873,13 +1198,27 @@ const TestClient = struct {
873 // The client must extend too: without this the SERVER stalls once 1198 // The client must extend too: without this the SERVER stalls once
874 // it has echoed a window's worth back, and the large-payload test 1199 // it has echoed a window's worth back, and the large-payload test
875 // below would hang rather than fail. 1200 // below would hang rather than fail.
876 _ = c.ngtcp2_conn_extend_max_stream_offset(cn, stream_id, datalen); 1201 if (s.extend) {
877 _ = c.ngtcp2_conn_extend_max_offset(cn, datalen); 1202 _ = c.ngtcp2_conn_extend_max_stream_offset(cn, stream_id, datalen);
1203 _ = c.ngtcp2_conn_extend_max_offset(cn, datalen);
1204 }
1205 if (s.verify) |exp| {
1206 const off = s.echoed;
1207 if (off + datalen > exp.len or
1208 !std.mem.eql(u8, exp[off..][0..datalen], data[0..datalen]))
1209 {
1210 s.mismatch = true;
1211 }
1212 }
878 s.echoed += datalen; 1213 s.echoed += datalen;
1214 if (s.recv_len + datalen <= s.recv_buf.len) {
1215 @memcpy(s.recv_buf[s.recv_len..][0..datalen], data[0..datalen]);
1216 s.recv_len += datalen;
1217 }
879 return 0; 1218 return 0;
880 } 1219 }
881 1220
882 fn init(server_addr: std.net.Address, key: Key) !TestClient { 1221 pub fn init(server_addr: std.net.Address, key: Key) !TestClient {
883 g_client_key = key; 1222 g_client_key = key;
884 const fd = try std.posix.socket( 1223 const fd = try std.posix.socket(
885 server_addr.any.family, 1224 server_addr.any.family,
@@ -903,7 +1242,7 @@ const TestClient = struct {
903 return self; 1242 return self;
904 } 1243 }
905 1244
906 fn start(self: *TestClient) !void { 1245 pub fn start(self: *TestClient) !void {
907 if (c.wolfSSL_Init() != c.WOLFSSL_SUCCESS) return error.TlsInit; 1246 if (c.wolfSSL_Init() != c.WOLFSSL_SUCCESS) return error.TlsInit;
908 const ctx = c.wolfSSL_CTX_new(c.wolfTLSv1_3_client_method()) orelse return error.TlsInit; 1247 const ctx = c.wolfSSL_CTX_new(c.wolfTLSv1_3_client_method()) orelse return error.TlsInit;
909 self.ssl_ctx = ctx; 1248 self.ssl_ctx = ctx;
@@ -976,7 +1315,7 @@ const TestClient = struct {
976 c.ngtcp2_conn_set_tls_native_handle(conn, ssl); 1315 c.ngtcp2_conn_set_tls_native_handle(conn, ssl);
977 } 1316 }
978 1317
979 fn drain(self: *TestClient) void { 1318 pub fn drain(self: *TestClient) void {
980 const conn = self.conn orelse return; 1319 const conn = self.conn orelse return;
981 var buf: [max_udp]u8 = undefined; 1320 var buf: [max_udp]u8 = undefined;
982 while (true) { 1321 while (true) {
@@ -1013,7 +1352,7 @@ const TestClient = struct {
1013 } 1352 }
1014 } 1353 }
1015 1354
1016 fn readable(self: *TestClient) void { 1355 pub fn readable(self: *TestClient) void {
1017 var buf: [65536]u8 = undefined; 1356 var buf: [65536]u8 = undefined;
1018 while (true) { 1357 while (true) {
1019 const n = std.posix.recv(self.fd, &buf, 0) catch return; 1358 const n = std.posix.recv(self.fd, &buf, 0) catch return;
@@ -1029,7 +1368,7 @@ const TestClient = struct {
1029 } 1368 }
1030 } 1369 }
1031 1370
1032 fn deinit(self: *TestClient) void { 1371 pub fn deinit(self: *TestClient) void {
1033 if (self.conn) |cn| c.ngtcp2_conn_del(cn); 1372 if (self.conn) |cn| c.ngtcp2_conn_del(cn);
1034 if (self.ssl) |s| c.wolfSSL_free(s); 1373 if (self.ssl) |s| c.wolfSSL_free(s);
1035 if (self.ssl_ctx) |x| c.wolfSSL_CTX_free(x); 1374 if (self.ssl_ctx) |x| c.wolfSSL_CTX_free(x);
@@ -1041,23 +1380,47 @@ const TestClient = struct {
1041 /// which is enough to prove bytes cross the seam in both directions. 1380 /// which is enough to prove bytes cross the seam in both directions.
1042 const EchoOwner = struct { 1381 const EchoOwner = struct {
1043 listener: *Listener = undefined, 1382 listener: *Listener = undefined,
1383 alloc: std.mem.Allocator = std.testing.allocator,
1384 id: u64 = 0,
1044 opened: usize = 0, 1385 opened: usize = 0,
1045 closed: usize = 0, 1386 closed: usize = 0,
1046 received: usize = 0, 1387 received: usize = 0,
1388 /// What the ring would not take yet. The daemon keeps exactly this
1389 /// queue for exactly this reason: `send` takes what fits and the owner
1390 /// holds — and bounds — the rest.
1391 backlog: std.ArrayList(u8) = .empty,
1047 1392
1048 fn onOpen(ctx: *anyopaque, _: u64) void { 1393 fn deinit(self: *EchoOwner) void {
1394 self.backlog.deinit(self.alloc);
1395 }
1396
1397 fn onOpen(ctx: *anyopaque, id: u64) void {
1049 const self: *EchoOwner = @ptrCast(@alignCast(ctx)); 1398 const self: *EchoOwner = @ptrCast(@alignCast(ctx));
1050 self.opened += 1; 1399 self.opened += 1;
1400 self.id = id;
1051 } 1401 }
1052 fn onData(ctx: *anyopaque, id: u64, bytes: []const u8) void { 1402 fn onData(ctx: *anyopaque, id: u64, bytes: []const u8) void {
1053 const self: *EchoOwner = @ptrCast(@alignCast(ctx)); 1403 const self: *EchoOwner = @ptrCast(@alignCast(ctx));
1054 self.received += bytes.len; 1404 self.received += bytes.len;
1055 self.listener.send(id, bytes) catch {}; 1405 self.id = id;
1406 self.backlog.appendSlice(self.alloc, bytes) catch return;
1407 self.flush();
1056 } 1408 }
1057 fn onClose(ctx: *anyopaque, _: u64) void { 1409 fn onClose(ctx: *anyopaque, _: u64) void {
1058 const self: *EchoOwner = @ptrCast(@alignCast(ctx)); 1410 const self: *EchoOwner = @ptrCast(@alignCast(ctx));
1059 self.closed += 1; 1411 self.closed += 1;
1060 } 1412 }
1413 /// Offer the backlog again. Called on every pump iteration as well as on
1414 /// arrival, because the room to accept it comes from acks, which arrive
1415 /// on their own schedule.
1416 fn flush(self: *EchoOwner) void {
1417 if (self.backlog.items.len == 0) return;
1418 const n = self.listener.send(self.id, self.backlog.items) catch return;
1419 if (n == 0) return;
1420 const rest = self.backlog.items.len - n;
1421 std.mem.copyForwards(u8, self.backlog.items[0..rest], self.backlog.items[n..]);
1422 self.backlog.shrinkRetainingCapacity(rest);
1423 }
1061 fn handler(self: *EchoOwner) Handler { 1424 fn handler(self: *EchoOwner) Handler {
1062 return .{ .ctx = self, .onOpen = onOpen, .onData = onData, .onClose = onClose }; 1425 return .{ .ctx = self, .onOpen = onOpen, .onData = onData, .onClose = onClose };
1063 } 1426 }
@@ -1082,6 +1445,9 @@ fn pump(l: *Listener, cl: *TestClient, ms: u64, done: *const fn (*EchoOwner, *Te
1082 waited += 10; 1445 waited += 10;
1083 } 1446 }
1084 l.tick(); 1447 l.tick();
1448 // The owner re-offers its backlog every iteration: acks free ring
1449 // space on their own schedule, and nothing else would notice.
1450 owner.flush();
1085 cl.drain(); 1451 cl.drain();
1086 for (l.conns) |slot| { 1452 for (l.conns) |slot| {
1087 if (slot) |cn| l.drain(cn); 1453 if (slot) |cn| l.drain(cn);
@@ -1090,9 +1456,14 @@ fn pump(l: *Listener, cl: *TestClient, ms: u64, done: *const fn (*EchoOwner, *Te
1090 return done(owner, cl); 1456 return done(owner, cl);
1091 } 1457 }
1092 1458
1093 fn loopbackListener(alloc: std.mem.Allocator, key: Key, owner: *EchoOwner) !struct { l: *Listener, addr: std.net.Address } { 1459 fn loopbackListener(
1460 alloc: std.mem.Allocator,
1461 key: Key,
1462 owner: *EchoOwner,
1463 idle_ms: u64,
1464 ) !struct { l: *Listener, addr: std.net.Address } {
1094 const bind = try std.net.Address.parseIp("127.0.0.1", 0); 1465 const bind = try std.net.Address.parseIp("127.0.0.1", 0);
1095 const l = try Listener.init(alloc, bind, key, owner.handler(), 5000); 1466 const l = try Listener.init(alloc, bind, key, owner.handler(), idle_ms);
1096 owner.listener = l; 1467 owner.listener = l;
1097 var actual: std.posix.sockaddr.storage = undefined; 1468 var actual: std.posix.sockaddr.storage = undefined;
1098 var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual)); 1469 var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
@@ -1105,7 +1476,8 @@ test "Listener: PSK handshake, Retry, and a payload larger than the initial wind
1105 const key: Key = .{ .bytes = [_]u8{0x5A} ** key_len }; 1476 const key: Key = .{ .bytes = [_]u8{0x5A} ** key_len };
1106 1477
1107 var owner: EchoOwner = .{}; 1478 var owner: EchoOwner = .{};
1108 const setup = try loopbackListener(alloc, key, &owner); 1479 defer owner.deinit();
1480 const setup = try loopbackListener(alloc, key, &owner, 5000);
1109 defer setup.l.deinit(); 1481 defer setup.l.deinit();
1110 1482
1111 var cl = try TestClient.init(setup.addr, key); 1483 var cl = try TestClient.init(setup.addr, key);
@@ -1170,7 +1542,8 @@ test "Listener: a client holding the wrong key never completes a handshake" {
1170 const wrong_key: Key = .{ .bytes = [_]u8{0x22} ** key_len }; 1542 const wrong_key: Key = .{ .bytes = [_]u8{0x22} ** key_len };
1171 1543
1172 var owner: EchoOwner = .{}; 1544 var owner: EchoOwner = .{};
1173 const setup = try loopbackListener(alloc, server_key, &owner); 1545 defer owner.deinit();
1546 const setup = try loopbackListener(alloc, server_key, &owner, 5000);
1174 defer setup.l.deinit(); 1547 defer setup.l.deinit();
1175 1548
1176 var cl = try TestClient.init(setup.addr, wrong_key); 1549 var cl = try TestClient.init(setup.addr, wrong_key);
@@ -1190,3 +1563,431 @@ test "Listener: a client holding the wrong key never completes a handshake" {
1190 try std.testing.expectEqual(@as(usize, 0), owner.opened); 1563 try std.testing.expectEqual(@as(usize, 0), owner.opened);
1191 try std.testing.expectEqual(@as(usize, 0), owner.received); 1564 try std.testing.expectEqual(@as(usize, 0), owner.received);
1192 } 1565 }
1566
1567 test "Listener: keepalive carries an idle connection past its idle timeout" {
1568 const alloc = std.testing.allocator;
1569 const key: Key = .{ .bytes = [_]u8{0x3C} ** key_len };
1570
1571 // Short enough that the test is quick, long enough that a handshake
1572 // fits inside it: the keepalive lands at 300ms, the timeout at 900ms.
1573 const idle_ms = 900;
1574 var owner: EchoOwner = .{};
1575 defer owner.deinit();
1576 const setup = try loopbackListener(alloc, key, &owner, idle_ms);
1577 defer setup.l.deinit();
1578
1579 var cl = try TestClient.init(setup.addr, key);
1580 defer cl.deinit();
1581 try cl.start();
1582 cl.drain();
1583 try std.testing.expect(pump(setup.l, &cl, 5000, struct {
1584 fn f(o: *EchoOwner, t: *TestClient) bool {
1585 return t.handshake_done and o.opened > 0;
1586 }
1587 }.f, &owner));
1588
1589 // Now say nothing at all for three times the idle timeout. `pump` only
1590 // counts quiet polls toward its budget, so the wall-clock silence is at
1591 // least this long — the keepalive PINGs it does carry are the whole
1592 // point, and they are not application traffic.
1593 _ = pump(setup.l, &cl, idle_ms * 3, struct {
1594 fn f(_: *EchoOwner, _: *TestClient) bool {
1595 return false;
1596 }
1597 }.f, &owner);
1598
1599 // Nothing timed out...
1600 try std.testing.expectEqual(@as(usize, 0), owner.closed);
1601 // ...and the connection is not merely un-reaped but still usable, which
1602 // a live conn struct on its own would not prove.
1603 cl.out = "still-here";
1604 cl.out_sent = 0;
1605 try std.testing.expect(pump(setup.l, &cl, 5000, struct {
1606 fn f(_: *EchoOwner, t: *TestClient) bool {
1607 return t.echoed >= "still-here".len;
1608 }
1609 }.f, &owner));
1610 try std.testing.expectEqual(@as(usize, 0), owner.closed);
1611 }
1612
1613 test "Egress: a byte does not move until it is acked, and the ring wraps" {
1614 const alloc = std.testing.allocator;
1615 var e: Egress = .{ .buf = try alloc.alloc(u8, 8) };
1616 defer e.deinit(alloc);
1617
1618 try std.testing.expectEqual(@as(usize, 5), e.push("hello"));
1619 var v: [2]c.ngtcp2_vec = undefined;
1620 try std.testing.expectEqual(@as(usize, 1), e.vecs(&v));
1621 try std.testing.expectEqual(@as(usize, 5), v[0].len);
1622 const base = e.buf.ptr;
1623 try std.testing.expectEqual(base, v[0].base);
1624
1625 // ngtcp2 takes three. Those three now have a pointer pointing at them
1626 // and must not move; the two behind them are still ours to offer.
1627 e.took(3);
1628 try std.testing.expectEqual(@as(usize, 1), e.vecs(&v));
1629 try std.testing.expectEqual(@as(usize, 2), v[0].len);
1630 try std.testing.expectEqual(base + 3, v[0].base);
1631
1632 // Room is what is left after everything HELD, sent or not — the three
1633 // in ngtcp2's hands are not free space just because they left the box.
1634 try std.testing.expectEqual(@as(usize, 3), e.freeSpace());
1635 try std.testing.expectEqual(@as(usize, 3), e.push("world"));
1636 try std.testing.expectEqual(@as(usize, 0), e.push("x"));
1637
1638 // An acknowledgement is the only thing that frees anything.
1639 e.ack(3);
1640 try std.testing.expectEqual(@as(usize, 3), e.freeSpace());
1641
1642 // ...and the write that follows wraps rather than shifting a byte.
1643 try std.testing.expectEqual(@as(usize, 3), e.push("abc"));
1644 try std.testing.expectEqual(@as(usize, 2), e.vecs(&v));
1645 try std.testing.expectEqualStrings("lowor", v[0].base[0..v[0].len]);
1646 try std.testing.expectEqualStrings("abc", v[1].base[0..v[1].len]);
1647 }
1648
1649 test "Egress: an ack can never free more than is outstanding" {
1650 const alloc = std.testing.allocator;
1651 var e: Egress = .{ .buf = try alloc.alloc(u8, 8) };
1652 defer e.deinit(alloc);
1653
1654 _ = e.push("abcd");
1655 e.took(2);
1656 // Two are in flight and two are still unsent. A callback claiming more
1657 // than is outstanding must not consume the unsent ones — they have
1658 // never been on the wire and cannot have been acknowledged.
1659 e.ack(99);
1660 try std.testing.expectEqual(@as(usize, 2), e.held);
1661 try std.testing.expectEqual(@as(usize, 2), e.unsent);
1662 var v: [2]c.ngtcp2_vec = undefined;
1663 try std.testing.expectEqual(@as(usize, 1), e.vecs(&v));
1664 try std.testing.expectEqualStrings("cd", v[0].base[0..v[0].len]);
1665 }
1666
1667 /// Get the peer's stream on the record. The server learns a stream id only
1668 /// when data arrives on it — the client opening one locally tells the server
1669 /// nothing — so a test that wants the server to SEND first has to make the
1670 /// client say something first. Waits until the round trip is acknowledged,
1671 /// so the egress ring is empty again and the next assertion is about the
1672 /// test's own bytes.
1673 fn openStream(l: *Listener, cl: *TestClient, owner: *EchoOwner) !void {
1674 cl.out = "hi";
1675 cl.out_sent = 0;
1676 cl.drain();
1677 const ok = pumpUntil(l, cl, owner, 10_000, struct {
1678 fn f(o: *EchoOwner, t: *TestClient) bool {
1679 return o.received >= 2 and t.echoed >= 2 and l_pending(o) == 0;
1680 }
1681 fn l_pending(o: *EchoOwner) usize {
1682 return o.listener.pendingBytes(o.id);
1683 }
1684 }.f);
1685 if (!ok) return error.StreamNeverOpened;
1686 cl.out = &.{};
1687 cl.out_sent = 0;
1688 cl.echoed = 0;
1689 }
1690
1691 /// Drive both ends for a wall-clock budget, unconditionally. `pump` counts
1692 /// only quiet iterations toward its deadline, which is right for waiting on
1693 /// an event and wrong for a bulk transfer that is busy the whole time and
1694 /// must still be prevented from hanging a suite.
1695 fn pumpUntil(
1696 l: *Listener,
1697 cl: *TestClient,
1698 owner: *EchoOwner,
1699 wall_ms: i64,
1700 done: *const fn (*EchoOwner, *TestClient) bool,
1701 ) bool {
1702 const deadline = std.time.milliTimestamp() + wall_ms;
1703 while (std.time.milliTimestamp() < deadline) {
1704 if (done(owner, cl)) return true;
1705 var fds = [_]std.posix.pollfd{
1706 .{ .fd = l.fd, .events = std.posix.POLL.IN, .revents = 0 },
1707 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 },
1708 };
1709 const ready = std.posix.poll(&fds, 5) catch return false;
1710 if (ready > 0) {
1711 if (fds[0].revents != 0) l.readable();
1712 if (fds[1].revents != 0) cl.readable();
1713 }
1714 l.tick();
1715 owner.flush();
1716 cl.drain();
1717 for (l.conns) |slot| {
1718 if (slot) |cn| l.drain(cn);
1719 }
1720 }
1721 return done(owner, cl);
1722 }
1723
1724 test "Listener: bytes survive retransmission, which is what the buffer is for" {
1725 const alloc = std.testing.allocator;
1726 const key: Key = .{ .bytes = [_]u8{0x6E} ** key_len };
1727
1728 var owner: EchoOwner = .{};
1729 defer owner.deinit();
1730 const setup = try loopbackListener(alloc, key, &owner, 10_000);
1731 defer setup.l.deinit();
1732
1733 var cl = try TestClient.init(setup.addr, key);
1734 defer cl.deinit();
1735
1736 // A deliberately tiny receive buffer, which is how loopback is made to
1737 // lose packets on purpose. Loss is the ONLY way into ngtcp2's
1738 // retransmission path, and that path re-reads the original bytes
1739 // through a pointer the listener handed over packets ago — so it is
1740 // also the only way to catch a buffer that recycled them underneath it.
1741 // Without the loss this test is just another echo; the 3MB test above
1742 // reached the same path by accident, about one run in twenty, and
1743 // segfaulted when it did.
1744 try std.posix.setsockopt(
1745 cl.fd,
1746 std.posix.SOL.SOCKET,
1747 std.posix.SO.RCVBUF,
1748 &std.mem.toBytes(@as(c_int, 64 * 1024)),
1749 );
1750
1751 try cl.start();
1752 cl.drain();
1753 try std.testing.expect(pumpUntil(setup.l, &cl, &owner, 10_000, struct {
1754 fn f(o: *EchoOwner, t: *TestClient) bool {
1755 return t.handshake_done and o.opened > 0;
1756 }
1757 }.f));
1758
1759 // Twice the egress ring, so the ring wraps and every byte's release
1760 // depends on an ack that may itself have been for a retransmission.
1761 const size = 2 * egress_cap;
1762 const payload = try alloc.alloc(u8, size);
1763 defer alloc.free(payload);
1764 for (payload, 0..) |*b, i| b.* = @truncate(i *% 31 +% 7);
1765
1766 cl.verify = payload;
1767 cl.out = payload;
1768 cl.out_sent = 0;
1769
1770 const ok = pumpUntil(setup.l, &cl, &owner, 60_000, struct {
1771 fn f(o: *EchoOwner, t: *TestClient) bool {
1772 _ = o;
1773 return t.echoed >= t.verify.?.len;
1774 }
1775 }.f);
1776 try std.testing.expect(ok);
1777
1778 // Count AND content. A transport that lost a retransmission and carried
1779 // on would land the right number of bytes in the wrong order, which no
1780 // length assertion can see.
1781 try std.testing.expectEqual(size, cl.echoed);
1782 try std.testing.expect(!cl.mismatch);
1783 try std.testing.expectEqual(size, owner.received);
1784 try std.testing.expectEqual(@as(usize, 0), owner.closed);
1785 }
1786
1787 test "Listener.send: takes what fits, refuses when full, and recovers on acks" {
1788 const alloc = std.testing.allocator;
1789 const key: Key = .{ .bytes = [_]u8{0x21} ** key_len };
1790
1791 var owner: EchoOwner = .{};
1792 defer owner.deinit();
1793 const setup = try loopbackListener(alloc, key, &owner, 10_000);
1794 defer setup.l.deinit();
1795
1796 var cl = try TestClient.init(setup.addr, key);
1797 defer cl.deinit();
1798 try cl.start();
1799 cl.drain();
1800 try std.testing.expect(pumpUntil(setup.l, &cl, &owner, 10_000, struct {
1801 fn f(o: *EchoOwner, t: *TestClient) bool {
1802 return t.handshake_done and o.opened > 0;
1803 }
1804 }.f));
1805
1806 try openStream(setup.l, &cl, &owner);
1807
1808 const big = try alloc.alloc(u8, egress_cap + 4096);
1809 defer alloc.free(big);
1810 @memset(big, 0x5A);
1811
1812 // Nobody is pumping the client from here, so nothing is acknowledged
1813 // and nothing is released. The first send fills the ring exactly and
1814 // reports the short count; the daemon keeps the remainder, which is the
1815 // whole point — an accept-everything send moved that backlog down here
1816 // where pending_cap could never see it.
1817 const first = try setup.l.send(owner.id, big);
1818 try std.testing.expectEqual(egress_cap, first);
1819 try std.testing.expectEqual(@as(usize, 0), try setup.l.send(owner.id, "x"));
1820 try std.testing.expectEqual(egress_cap, setup.l.pendingBytes(owner.id));
1821
1822 // Now let the client read and acknowledge: space comes back, and it
1823 // comes back from acks rather than from having handed bytes to ngtcp2.
1824 // Waiting on the ACK, not on the arrival: the last byte reaching the
1825 // client is not the same event as the ring being free to reuse it, and
1826 // conflating them is what this buffer exists to stop anyone doing.
1827 try std.testing.expect(pumpUntil(setup.l, &cl, &owner, 20_000, struct {
1828 fn f(o: *EchoOwner, _: *TestClient) bool {
1829 return o.listener.pendingBytes(o.id) == 0;
1830 }
1831 }.f));
1832 try std.testing.expectEqual(egress_cap, cl.echoed);
1833 try std.testing.expect(try setup.l.send(owner.id, "room again") > 0);
1834
1835 // An id nobody owns is an error, not a silent success.
1836 try std.testing.expectError(error.NoSuchConn, setup.l.send(owner.id + 999, "x"));
1837 }
1838
1839 test "Listener: a full peer window blocks the stream without stopping the ACKs" {
1840 const alloc = std.testing.allocator;
1841 const key: Key = .{ .bytes = [_]u8{0x4B} ** key_len };
1842
1843 // Short enough that a connection nobody is servicing dies inside the
1844 // test, which is exactly the failure being guarded against: treating
1845 // the blocked-stream return as fatal abandons the egress loop, and the
1846 // ACKs and keepalives that share it never leave either.
1847 const idle_ms = 1500;
1848 var owner: EchoOwner = .{};
1849 defer owner.deinit();
1850 const setup = try loopbackListener(alloc, key, &owner, idle_ms);
1851 defer setup.l.deinit();
1852
1853 var cl = try TestClient.init(setup.addr, key);
1854 defer cl.deinit();
1855 try cl.start();
1856 cl.drain();
1857 try std.testing.expect(pumpUntil(setup.l, &cl, &owner, 10_000, struct {
1858 fn f(o: *EchoOwner, t: *TestClient) bool {
1859 return t.handshake_done and o.opened > 0;
1860 }
1861 }.f));
1862
1863 try openStream(setup.l, &cl, &owner);
1864
1865 // The client consumes but never grants more window, so the peer's
1866 // stream credit runs out and stays out.
1867 cl.extend = false;
1868
1869 const big = try alloc.alloc(u8, 4 * egress_cap);
1870 defer alloc.free(big);
1871 @memset(big, 0x33);
1872 try owner.backlog.appendSlice(alloc, big);
1873 owner.flush();
1874
1875 // Well past the idle timeout, with the stream blocked the entire time.
1876 _ = pumpUntil(setup.l, &cl, &owner, idle_ms * 3, struct {
1877 fn f(_: *EchoOwner, _: *TestClient) bool {
1878 return false;
1879 }
1880 }.f);
1881
1882 // The stream really did block — the client cannot have taken it all.
1883 try std.testing.expect(cl.echoed < big.len);
1884 // ...and the connection is alive, which it can only be if packets that
1885 // are not stream data kept flowing while it was blocked.
1886 try std.testing.expectEqual(@as(usize, 0), owner.closed);
1887 try std.testing.expect(setup.l.find(owner.id) != null);
1888 }
1889
1890 test "Listener: a packet addressed to any advertised CID reaches its connection" {
1891 const alloc = std.testing.allocator;
1892 const key: Key = .{ .bytes = [_]u8{0x9C} ** key_len };
1893
1894 var owner: EchoOwner = .{};
1895 defer owner.deinit();
1896 const setup = try loopbackListener(alloc, key, &owner, 10_000);
1897 defer setup.l.deinit();
1898
1899 var cl = try TestClient.init(setup.addr, key);
1900 defer cl.deinit();
1901 try cl.start();
1902 cl.drain();
1903 try std.testing.expect(pumpUntil(setup.l, &cl, &owner, 10_000, struct {
1904 fn f(o: *EchoOwner, t: *TestClient) bool {
1905 return t.handshake_done and o.opened > 0;
1906 }
1907 }.f));
1908
1909 const cn = setup.l.find(owner.id).?;
1910 cn.refreshCids();
1911 // Non-vacuity first: with only one CID there is no second one to route
1912 // by, and everything below would pass for the wrong reason.
1913 try std.testing.expect(cn.ncids > 1);
1914
1915 var secondary: ?*c.ngtcp2_cid = null;
1916 for (cn.cids[0..cn.ncids]) |*cid| {
1917 if (!cidEql(cid, cn.scid.data[0..cn.scid.datalen])) {
1918 secondary = cid;
1919 break;
1920 }
1921 }
1922 try std.testing.expect(secondary != null);
1923 const sec = secondary.?;
1924 try std.testing.expect(cn.matches(sec.data[0..sec.datalen]));
1925
1926 // A CID nobody advertised belongs to nobody.
1927 const stranger_cid = [_]u8{0xEE} ** 8;
1928 try std.testing.expect(!cn.matches(&stranger_cid));
1929
1930 // Now the routing itself. The observable is what happens to a packet
1931 // that MISSES: it falls through to `accept`, which answers a token-less
1932 // Initial with a Retry. So a probe socket that receives nothing is proof
1933 // the packet was delivered to the connection instead — and the same
1934 // probe receiving a Retry for an unknown CID proves the packet was
1935 // well-formed enough for `accept` to have answered it.
1936 const probe = try std.posix.socket(
1937 std.posix.AF.INET,
1938 std.posix.SOCK.DGRAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC,
1939 0,
1940 );
1941 defer std.posix.close(probe);
1942 const probe_bind = try std.net.Address.parseIp("127.0.0.1", 0);
1943 try std.posix.bind(probe, &probe_bind.any, probe_bind.getOsSockLen());
1944 var probe_addr: std.posix.sockaddr.storage = undefined;
1945 var probe_len: std.posix.socklen_t = @sizeOf(@TypeOf(probe_addr));
1946 try std.posix.getsockname(probe, @ptrCast(&probe_addr), &probe_len);
1947
1948 var pkt: [1300]u8 = undefined;
1949 try std.testing.expect(!probeGotAnything(probe));
1950
1951 // Addressed to a CID this connection advertised: delivered, not
1952 // answered.
1953 buildInitial(&pkt, sec.data[0..sec.datalen]);
1954 setup.l.route(&pkt, &probe_addr, probe_len);
1955 try std.testing.expect(!probeGotAnything(probe));
1956
1957 // Addressed to nobody: `accept` answers with a Retry, which is what
1958 // every migrated packet used to get.
1959 buildInitial(&pkt, &stranger_cid);
1960 setup.l.route(&pkt, &probe_addr, probe_len);
1961 try std.testing.expect(probeGotAnything(probe));
1962 }
1963
1964 /// A syntactically valid, cryptographically meaningless Initial packet
1965 /// addressed to `dcid`. Enough for `ngtcp2_accept` to recognise it and
1966 /// answer, which is all this needs to tell routing hit from routing miss.
1967 fn buildInitial(pkt: *[1300]u8, dcid: []const u8) void {
1968 @memset(pkt, 0);
1969 var i: usize = 0;
1970 pkt[i] = 0xC3; // long header, fixed bit, Initial, 4-byte packet number
1971 i += 1;
1972 std.mem.writeInt(u32, pkt[i..][0..4], 1, .big); // version 1
1973 i += 4;
1974 pkt[i] = @intCast(dcid.len);
1975 i += 1;
1976 @memcpy(pkt[i..][0..dcid.len], dcid);
1977 i += dcid.len;
1978 pkt[i] = 8; // source CID length
1979 i += 1;
1980 @memset(pkt[i..][0..8], 0x5A);
1981 i += 8;
1982 pkt[i] = 0; // token length, varint 0
1983 i += 1;
1984 // Length: a two-byte varint covering everything left.
1985 const rest: u16 = @intCast(pkt.len - i - 2);
1986 std.mem.writeInt(u16, pkt[i..][0..2], rest | 0x4000, .big);
1987 }
1988
1989 fn probeGotAnything(fd: std.posix.fd_t) bool {
1990 var buf: [2048]u8 = undefined;
1991 const n = std.posix.recv(fd, &buf, 0) catch return false;
1992 return n > 0;
1993 }
src/server.zig
Old New
@@ -9,6 +9,7 @@ const std = @import("std");
9 const Engine = @import("engine").Engine; 9 const Engine = @import("engine").Engine;
10 const Pty = @import("pty").Pty; 10 const Pty = @import("pty").Pty;
11 const proto = @import("protocol"); 11 const proto = @import("protocol");
12 const quic = @import("quic");
12 13
13 const max_clients = 8; 14 const max_clients = 8;
14 const max_observers = 4; 15 const max_observers = 4;
@@ -212,6 +213,9 @@ pub fn installSignalHandlers() void {
212 /// need to change when it does. 213 /// need to change when it does.
213 const Sink = union(enum) { 214 const Sink = union(enum) {
214 socket: std.posix.fd_t, 215 socket: std.posix.fd_t,
216 /// A QUIC peer: the listener that owns the shared UDP socket, plus the
217 /// connection id within it. Note what is NOT here — a descriptor.
218 quic: struct { listener: *quic.Listener, id: u64 },
215 219
216 /// The descriptor to poll for this client, or -1 for "nothing of its 220 /// The descriptor to poll for this client, or -1 for "nothing of its
217 /// own" — poll(2) ignores negative fds, which is exactly the behaviour 221 /// own" — poll(2) ignores negative fds, which is exactly the behaviour
@@ -220,12 +224,20 @@ const Sink = union(enum) {
220 fn pollFd(self: Sink) std.posix.fd_t { 224 fn pollFd(self: Sink) std.posix.fd_t {
221 return switch (self) { 225 return switch (self) {
222 .socket => |fd| fd, 226 .socket => |fd| fd,
227 .quic => -1,
223 }; 228 };
224 } 229 }
225 230
226 /// Hand bytes to the kernel without blocking. Same contract as the 231 /// Hand bytes onward without blocking, returning how many were taken.
227 /// send(2) it replaces: returns what was accepted, WouldBlock when the 232 ///
228 /// buffer is full, any other error means the peer is unusable. 233 /// For a socket that is send(2) straight to the kernel. For QUIC it is a
234 /// queue-and-drain into the connection's bounded egress ring, which
235 /// takes what it has room for and says so. Both therefore obey the same
236 /// contract — a short return means the rest stays in `pending` and the
237 /// cap gets to judge it — and that symmetry is load-bearing: while the
238 /// QUIC arm accepted everything, `pending_cap` could never trip for a
239 /// QUIC client, and the unbounded growth simply moved down into the
240 /// listener where nothing was watching for it.
229 fn send(self: Sink, bytes: []const u8) !usize { 241 fn send(self: Sink, bytes: []const u8) !usize {
230 return switch (self) { 242 return switch (self) {
231 .socket => |fd| std.posix.send( 243 .socket => |fd| std.posix.send(
@@ -233,12 +245,33 @@ const Sink = union(enum) {
233 bytes, 245 bytes,
234 std.posix.MSG.DONTWAIT | std.posix.MSG.NOSIGNAL, 246 std.posix.MSG.DONTWAIT | std.posix.MSG.NOSIGNAL,
235 ), 247 ),
248 .quic => |q| q.listener.send(q.id, bytes),
249 };
250 }
251
252 /// Bytes this sink has taken but not yet got off the box. Zero for a
253 /// socket: once the kernel has it, it is the kernel's problem. For QUIC
254 /// the send is not finished until the peer acknowledges, so the listener
255 /// is the only thing that knows.
256 fn inFlight(self: Sink) usize {
257 return switch (self) {
258 .socket => 0,
259 .quic => |q| q.listener.pendingBytes(q.id),
236 }; 260 };
237 } 261 }
238 262
263 /// Close THIS CLIENT'S channel — never the transport it shares.
264 ///
265 /// For a socket those are the same object, which is exactly why the
266 /// distinction has to be written down before QUIC exists: a QUIC client
267 /// shares one UDP socket with every other session on this daemon, so
268 /// closing that socket because one client left would take down every
269 /// other client with it. `closeConn` tears down the one connection and
270 /// leaves the listener's socket alone. Pinned by a test.
239 fn close(self: Sink) void { 271 fn close(self: Sink) void {
240 switch (self) { 272 switch (self) {
241 .socket => |fd| std.posix.close(fd), 273 .socket => |fd| std.posix.close(fd),
274 .quic => |q| q.listener.closeConn(q.id),
242 } 275 }
243 } 276 }
244 }; 277 };
@@ -294,6 +327,10 @@ pub const Server = struct {
294 /// have_seq must be snapshotted, not deltaed. Never 0 — that value is 327 /// have_seq must be snapshotted, not deltaed. Never 0 — that value is
295 /// reserved for a client saying "I hold nothing". 328 /// reserved for a client saying "I hold nothing".
296 epoch: u64, 329 epoch: u64,
330 /// The QUIC listener, when `--quic` was given. Optional by design:
331 /// QUIC is opt-in per invocation and the unix socket is unaffected by
332 /// its presence or absence.
333 quic_listener: ?*quic.Listener = null,
297 /// Row-level change tracking behind the delta stream. 334 /// Row-level change tracking behind the delta stream.
298 tracker: DeltaTracker = .{}, 335 tracker: DeltaTracker = .{},
299 stats: Stats = .{}, 336 stats: Stats = .{},
@@ -453,7 +490,7 @@ pub const Server = struct {
453 } 490 }
454 491
455 const obs_base = 2 + max_clients; 492 const obs_base = 2 + max_clients;
456 var fds: [2 + max_clients + max_observers]std.posix.pollfd = undefined; 493 var fds: [2 + max_clients + max_observers + 1]std.posix.pollfd = undefined;
457 fds[0] = .{ .fd = self.pty.master, .events = std.posix.POLL.IN, .revents = 0 }; 494 fds[0] = .{ .fd = self.pty.master, .events = std.posix.POLL.IN, .revents = 0 };
458 fds[1] = .{ .fd = self.listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }; 495 fds[1] = .{ .fd = self.listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 };
459 for (&self.clients, 0..) |*slot, i| { 496 for (&self.clients, 0..) |*slot, i| {
@@ -471,7 +508,35 @@ pub const Server = struct {
471 for (self.observers, 0..) |slot, i| { 508 for (self.observers, 0..) |slot, i| {
472 fds[obs_base + i] = .{ .fd = slot orelse -1, .events = std.posix.POLL.IN, .revents = 0 }; 509 fds[obs_base + i] = .{ .fd = slot orelse -1, .events = std.posix.POLL.IN, .revents = 0 };
473 } 510 }
474 _ = try std.posix.poll(&fds, timeout_ms); 511 // One extra descriptor for every QUIC client there will ever be:
512 // they share it, which is the whole reason a client slot cannot be
513 // a descriptor.
514 const quic_idx = fds.len - 1;
515 fds[quic_idx] = .{
516 .fd = if (self.quic_listener) |q| q.pollFd() else -1,
517 .events = std.posix.POLL.IN,
518 .revents = 0,
519 };
520
521 // ngtcp2's timers, folded in: the listener's earliest deadline
522 // shortens this poll, so retransmits and idle timeouts happen on
523 // time without a timerfd or a second loop.
524 var wait_ms = timeout_ms;
525 if (self.quic_listener) |q| wait_ms = q.timeoutMs(timeout_ms);
526 _ = try std.posix.poll(&fds, wait_ms);
527
528 if (self.quic_listener) |q| {
529 if (fds[quic_idx].revents != 0) q.readable();
530 // Unconditional: expiry work is due whether or not a packet
531 // arrived, and this is the only place it can happen.
532 q.tick();
533 // Acks that just arrived are what frees room in a connection's
534 // egress ring, and a QUIC client has no descriptor of its own to
535 // go writable. Without this, a client whose ring filled would
536 // sit on its backlog until the next frame happened to be queued
537 // for it — progress by coincidence rather than by design.
538 self.flushQuicClients();
539 }
475 540
476 if (fds[0].revents != 0) { 541 if (fds[0].revents != 0) {
477 var buf: [64 * 1024]u8 = undefined; 542 var buf: [64 * 1024]u8 = undefined;
@@ -583,39 +648,79 @@ pub const Server = struct {
583 fn drainPending(self: *Server, budget_ms: i64) void { 648 fn drainPending(self: *Server, budget_ms: i64) void {
584 const deadline = std.time.milliTimestamp() + budget_ms; 649 const deadline = std.time.milliTimestamp() + budget_ms;
585 while (true) { 650 while (true) {
586 var fds: [max_clients]std.posix.pollfd = undefined; 651 // One extra slot for the QUIC listener: its readability is how
652 // acknowledgements arrive, and acknowledgements are the only
653 // thing that frees room in a connection's egress ring.
654 var fds: [max_clients + 1]std.posix.pollfd = undefined;
587 var slots: [max_clients]usize = undefined; 655 var slots: [max_clients]usize = undefined;
588 var n: usize = 0; 656 var n_sock: usize = 0;
589 var owed: usize = 0; 657 var owed: usize = 0;
658 var quic_owed: usize = 0;
590 for (&self.clients, 0..) |*slot, i| { 659 for (&self.clients, 0..) |*slot, i| {
591 if (slot.*) |*c| { 660 if (slot.* == null) continue;
592 if (c.pending.items.len == 0) continue; 661 if (slot.*.?.sink == .quic) {
593 owed += c.pending.items.len; 662 // A QUIC client has no descriptor of its own to wait on,
594 slots[n] = i; 663 // so its egress is driven directly. It owes in two
595 fds[n] = .{ .fd = c.sink.pollFd(), .events = std.posix.POLL.OUT, .revents = 0 }; 664 // places, and only counting the first is how this arm
596 n += 1; 665 // used to look like it worked: bytes still queued in the
666 // slot, AND bytes the ring took but the peer has not
667 // acknowledged. A send is not finished when it is
668 // accepted, it is finished when it is acked.
669 self.flushClient(i);
670 if (self.clients[i]) |*c| {
671 const still = c.pending.items.len + c.sink.inFlight();
672 owed += still;
673 quic_owed += still;
674 }
675 continue;
597 } 676 }
677 const c = &self.clients[i].?;
678 if (c.pending.items.len == 0) continue;
679 owed += c.pending.items.len;
680 slots[n_sock] = i;
681 fds[n_sock] = .{ .fd = c.sink.pollFd(), .events = std.posix.POLL.OUT, .revents = 0 };
682 n_sock += 1;
598 } 683 }
599 if (n == 0) return; // everything delivered 684 // Give QUIC egress a chance to leave the box before deciding
685 // there is nothing left to wait for.
686 if (self.quic_listener) |q| q.tick();
687 if (owed == 0) return;
600 688
601 const remaining = deadline - std.time.milliTimestamp(); 689 const remaining = deadline - std.time.milliTimestamp();
602 if (remaining <= 0) return; 690 if (remaining <= 0) return;
691
692 var n = n_sock;
693 var quic_idx: ?usize = null;
694 if (quic_owed > 0) {
695 if (self.quic_listener) |q| {
696 quic_idx = n;
697 fds[n] = .{ .fd = q.pollFd(), .events = std.posix.POLL.IN, .revents = 0 };
698 n += 1;
699 }
700 }
701 if (n == 0) return; // owed, but nothing left that could pay
702
603 const ready = std.posix.poll(fds[0..n], @intCast(remaining)) catch return; 703 const ready = std.posix.poll(fds[0..n], @intCast(remaining)) catch return;
604 if (ready == 0) return; // out of time with bytes still owed 704 if (ready == 0) return; // out of time with bytes still owed
605 705
606 for (fds[0..n], slots[0..n]) |pfd, i| { 706 if (quic_idx) |qi| {
707 if (fds[qi].revents != 0) {
708 if (self.quic_listener) |q| q.readable();
709 }
710 }
711 for (fds[0..n_sock], slots[0..n_sock]) |pfd, i| {
607 // Non-POLLOUT revents (the peer hung up) reach flushClient 712 // Non-POLLOUT revents (the peer hung up) reach flushClient
608 // too: its send fails and the client is dropped, which is 713 // too: its send fails and the client is dropped, which is
609 // also what stops this loop from retrying a dead fd. 714 // also what stops this loop from retrying a dead fd.
610 if (pfd.revents != 0) self.flushClient(i); 715 if (pfd.revents != 0) self.flushClient(i);
611 } 716 }
612 717
613 // Guard against a poll that keeps reporting writable while send 718 // Guard against a poll that keeps reporting ready while nothing
614 // keeps refusing: without this the loop would spin hot until the 719 // actually moves: without this the loop would spin hot until the
615 // deadline instead of giving up on a peer making no progress. 720 // deadline instead of giving up on a peer making no progress.
616 var still: usize = 0; 721 var still: usize = 0;
617 for (&self.clients) |*slot| { 722 for (&self.clients) |*slot| {
618 if (slot.*) |*c| still += c.pending.items.len; 723 if (slot.*) |*c| still += c.pending.items.len + c.sink.inFlight();
619 } 724 }
620 if (still >= owed) return; 725 if (still >= owed) return;
621 } 726 }
@@ -661,6 +766,96 @@ pub const Server = struct {
661 } 766 }
662 } 767 }
663 768
769 /// Offer every QUIC client's backlog to its ring again. Cheap when there
770 /// is nothing owed, which is the common case.
771 fn flushQuicClients(self: *Server) void {
772 for (&self.clients, 0..) |*slot, i| {
773 const cl = &(slot.* orelse continue);
774 if (cl.sink != .quic) continue;
775 if (cl.pending.items.len > 0) self.flushClient(i);
776 }
777 }
778
779 // ---- QUIC glue -------------------------------------------------------
780 //
781 // Three callbacks, and between them they contain the entire difference
782 // between a QUIC client and a socket client. Everything past pushInbound
783 // is code that has never heard of either.
784
785 fn quicOnOpen(ctx: *anyopaque, id: u64) void {
786 const self: *Server = @ptrCast(@alignCast(ctx));
787 const listener = self.quic_listener orelse return;
788 const slot = self.freeClientSlot() orelse {
789 // Session full: the same answer the socket path gives, sent the
790 // same way, then the connection goes.
791 // A short accept would be a truncated refusal, which reads as a
792 // corrupt frame rather than a "no". The ring is empty on a
793 // connection that has just opened and this frame is a handful of
794 // bytes, so a partial take here means something is very wrong;
795 // either way, closing is the answer.
796 _ = listener.send(id, &frameBytes(.exit_status, &.{1})) catch {};
797 listener.closeConn(id);
798 return;
799 };
800 self.clients[slot] = .{ .sink = .{ .quic = .{ .listener = listener, .id = id } } };
801 }
802
803 fn quicOnData(ctx: *anyopaque, id: u64, bytes: []const u8) void {
804 const self: *Server = @ptrCast(@alignCast(ctx));
805 const i = self.slotForQuic(id) orelse return;
806 self.pushInbound(i, bytes);
807 }
808
809 fn quicOnClose(ctx: *anyopaque, id: u64) void {
810 const self: *Server = @ptrCast(@alignCast(ctx));
811 const i = self.slotForQuic(id) orelse return;
812 // The connection is already gone; drop the slot without asking the
813 // sink to close it again.
814 if (self.clients[i]) |*slot| {
815 slot.pending.deinit(self.alloc);
816 slot.inbound.deinit(self.alloc);
817 }
818 self.clients[i] = null;
819 }
820
821 fn slotForQuic(self: *Server, id: u64) ?usize {
822 for (self.clients, 0..) |slot, i| {
823 const cs = slot orelse continue;
824 switch (cs.sink) {
825 .quic => |q| if (q.id == id) return i,
826 else => {},
827 }
828 }
829 return null;
830 }
831
832 pub fn quicHandler(self: *Server) quic.Handler {
833 return .{
834 .ctx = self,
835 .onOpen = quicOnOpen,
836 .onData = quicOnData,
837 .onClose = quicOnClose,
838 };
839 }
840
841 /// Adopt a listener built by the caller (main.zig, or a test). The
842 /// server does not own the socket, only the reference — deinit leaves
843 /// the listener to its creator, which keeps the ownership story the
844 /// same as the unix listener's.
845 pub fn attachQuic(self: *Server, listener: *quic.Listener) void {
846 self.quic_listener = listener;
847 }
848
849 /// One frame, on the stack, for the two places the QUIC path has to
850 /// speak before a client slot exists.
851 fn frameBytes(t: proto.MsgType, payload: []const u8) [6]u8 {
852 var buf: [6]u8 = undefined;
853 buf[0] = @intFromEnum(t);
854 std.mem.writeInt(u32, buf[1..5], @intCast(payload.len), .little);
855 buf[5] = if (payload.len > 0) payload[0] else 0;
856 return buf;
857 }
858
664 fn hasClients(self: *const Server) bool { 859 fn hasClients(self: *const Server) bool {
665 for (self.clients) |slot| { 860 for (self.clients) |slot| {
666 if (slot != null) return true; 861 if (slot != null) return true;
@@ -686,6 +881,11 @@ pub const Server = struct {
686 fn serviceClient(self: *Server, i: usize) void { 881 fn serviceClient(self: *Server, i: usize) void {
687 const fd = switch (self.clients[i].?.sink) { 882 const fd = switch (self.clients[i].?.sink) {
688 .socket => |fd| fd, 883 .socket => |fd| fd,
884 // A QUIC client has no descriptor of its own to read: its bytes
885 // arrive from the shared UDP socket and enter through
886 // pushInbound. Poll never reports it readable (pollFd is -1), so
887 // reaching here would mean the poll bookkeeping had drifted.
888 .quic => return,
689 }; 889 };
690 const frame = proto.readFrame(self.alloc, fd) catch { 890 const frame = proto.readFrame(self.alloc, fd) catch {
691 self.dropClient(i); 891 self.dropClient(i);
@@ -3157,3 +3357,328 @@ test "DeltaTracker: a resize behind the tracker's back resyncs instead of over-r
3157 else => return error.ExpectedDiscontinuity, 3357 else => return error.ExpectedDiscontinuity,
3158 } 3358 }
3159 } 3359 }
3360
3361 // ---------------------------------------------------------------------------
3362 // QUIC integration tests.
3363 //
3364 // These two exist because the claims they check — that a QUIC client still
3365 // gets the shell's exit code, and that one client leaving does not take the
3366 // others with it — were, until they were written, assertions of mine rather
3367 // than evidence.
3368 // ---------------------------------------------------------------------------
3369
3370 /// Bring a Server up with a QUIC listener bound to an ephemeral loopback
3371 /// port, and hand back the address a client should dial.
3372 fn quicTestServer(srv: *Server, key: quic.Key) !struct { l: *quic.Listener, addr: std.net.Address } {
3373 const bind = try std.net.Address.parseIp("127.0.0.1", 0);
3374 const l = try quic.Listener.init(srv.alloc, bind, key, srv.quicHandler(), 5000);
3375 srv.attachQuic(l);
3376 var actual: std.posix.sockaddr.storage = undefined;
3377 var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
3378 try std.posix.getsockname(l.pollFd(), @ptrCast(&actual), &len);
3379 return .{ .l = l, .addr = std.net.Address.initPosix(@ptrCast(@alignCast(&actual))) };
3380 }
3381
3382 /// Drive the daemon and up to two QUIC clients until `done`, or the budget
3383 /// runs out. Single-threaded: the daemon's own pump is what services the
3384 /// listener, which is the integration under test.
3385 fn quicPump(
3386 srv: *Server,
3387 clients: []*quic.TestClient,
3388 budget_ms: u64,
3389 ctx: anytype,
3390 done: *const fn (@TypeOf(ctx)) bool,
3391 ) !?u8 {
3392 var waited: u64 = 0;
3393 var exit_code: ?u8 = null;
3394 while (waited < budget_ms) {
3395 if (done(ctx)) return exit_code;
3396 if (exit_code == null) {
3397 exit_code = try srv.pumpOnce(5);
3398 }
3399 for (clients) |cl| {
3400 cl.drain();
3401 var pfd = [_]std.posix.pollfd{.{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 }};
3402 if ((std.posix.poll(&pfd, 1) catch 0) > 0) cl.readable();
3403 }
3404 waited += 6;
3405 }
3406 return exit_code;
3407 }
3408
3409 /// Find a frame of `want` in a client's received bytes, returning its
3410 /// payload. The client is handed a byte stream, so this does the same
3411 /// framing walk a real client would.
3412 fn findFrame(bytes: []const u8, want: proto.MsgType) ?[]const u8 {
3413 var off: usize = 0;
3414 while (off + 5 <= bytes.len) {
3415 const len = std.mem.readInt(u32, bytes[off + 1 ..][0..4], .little);
3416 if (off + 5 + len > bytes.len) return null;
3417 if (@as(proto.MsgType, @enumFromInt(bytes[off])) == want) {
3418 return bytes[off + 5 ..][0..len];
3419 }
3420 off += 5 + len;
3421 }
3422 return null;
3423 }
3424
3425 fn attachOver(cl: *quic.TestClient, buf: *std.ArrayList(u8), alloc: std.mem.Allocator) !void {
3426 try proto.appendFrame(buf, alloc, .attach, &proto.encodeAttach(80, 24, 0, 0));
3427 cl.out = buf.items;
3428 cl.drain();
3429 }
3430
3431 test "Server: a QUIC client still receives the shell's exit status" {
3432 const alloc = std.testing.allocator;
3433 var tmp = std.testing.tmpDir(.{});
3434 defer tmp.cleanup();
3435 var path_buf: [256]u8 = undefined;
3436 const dir_path = try tmp.dir.realpath(".", &path_buf);
3437 const sock_path = try std.fmt.allocPrint(alloc, "{s}/qexit.sock", .{dir_path});
3438 defer alloc.free(sock_path);
3439
3440 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
3441 defer srv.deinit();
3442 const key: quic.Key = .{ .bytes = [_]u8{0x31} ** quic.key_len };
3443 const q = try quicTestServer(&srv, key);
3444 defer q.l.deinit();
3445
3446 var cl = try quic.TestClient.init(q.addr, key);
3447 defer cl.deinit();
3448 try cl.start();
3449 cl.drain();
3450
3451 var out: std.ArrayList(u8) = .empty;
3452 defer out.deinit(alloc);
3453 try attachOver(&cl, &out, alloc);
3454
3455 // Attached and carrying state: the frame path works before we start
3456 // asking about the harder one.
3457 var only = [_]*quic.TestClient{&cl};
3458 _ = try quicPump(&srv, &only, 8000, &cl, struct {
3459 fn f(t: *quic.TestClient) bool {
3460 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null;
3461 }
3462 }.f);
3463 try std.testing.expect(findFrame(cl.recv_buf[0..cl.recv_len], .snapshot) != null);
3464
3465 // Now the case drainPending gets wrong when a QUIC slot contributes a
3466 // -1 descriptor: the shell exits, the exit_status is queued, and the
3467 // 250ms budget must actually deliver it rather than sleeping through it.
3468 try proto.appendFrame(&out, alloc, .input, "exit 7\n");
3469 cl.out = out.items;
3470 cl.drain();
3471
3472 const code = try quicPump(&srv, &only, 15000, &cl, struct {
3473 fn f(t: *quic.TestClient) bool {
3474 return findFrame(t.recv_buf[0..t.recv_len], .exit_status) != null;
3475 }
3476 }.f);
3477 _ = code;
3478
3479 const status = findFrame(cl.recv_buf[0..cl.recv_len], .exit_status);
3480 try std.testing.expect(status != null);
3481 try std.testing.expectEqual(@as(usize, 1), status.?.len);
3482 try std.testing.expectEqual(@as(u8, 7), status.?[0]);
3483 }
3484
3485 test "Server: one QUIC client leaving does not disturb the other" {
3486 const alloc = std.testing.allocator;
3487 var tmp = std.testing.tmpDir(.{});
3488 defer tmp.cleanup();
3489 var path_buf: [256]u8 = undefined;
3490 const dir_path = try tmp.dir.realpath(".", &path_buf);
3491 const sock_path = try std.fmt.allocPrint(alloc, "{s}/qtwo.sock", .{dir_path});
3492 defer alloc.free(sock_path);
3493
3494 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
3495 defer srv.deinit();
3496 const key: quic.Key = .{ .bytes = [_]u8{0x77} ** quic.key_len };
3497 const q = try quicTestServer(&srv, key);
3498 defer q.l.deinit();
3499
3500 var a = try quic.TestClient.init(q.addr, key);
3501 defer a.deinit();
3502 try a.start();
3503 a.drain();
3504 var b = try quic.TestClient.init(q.addr, key);
3505 defer b.deinit();
3506 try b.start();
3507 b.drain();
3508
3509 var abuf: std.ArrayList(u8) = .empty;
3510 defer abuf.deinit(alloc);
3511 var bbuf: std.ArrayList(u8) = .empty;
3512 defer bbuf.deinit(alloc);
3513 try attachOver(&a, &abuf, alloc);
3514 try attachOver(&b, &bbuf, alloc);
3515
3516 var both = [_]*quic.TestClient{ &a, &b };
3517 _ = try quicPump(&srv, &both, 10000, &b, struct {
3518 fn f(t: *quic.TestClient) bool {
3519 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null;
3520 }
3521 }.f);
3522 try std.testing.expect(findFrame(a.recv_buf[0..a.recv_len], .snapshot) != null);
3523 try std.testing.expect(findFrame(b.recv_buf[0..b.recv_len], .snapshot) != null);
3524
3525 // A leaves. Sink.close() must tear down A's connection and NOT the UDP
3526 // socket every other client is reached through — for a socket client
3527 // those are the same object, which is exactly why this needs a test.
3528 try proto.appendFrame(&abuf, alloc, .detach, "");
3529 a.out = abuf.items;
3530 a.drain();
3531 _ = try quicPump(&srv, &both, 4000, &srv, struct {
3532 fn f(s: *Server) bool {
3533 var n: usize = 0;
3534 for (s.clients) |slot| {
3535 if (slot != null) n += 1;
3536 }
3537 return n == 1;
3538 }
3539 }.f);
3540
3541 // B is still served: a marker typed now must come back to it.
3542 const before = b.recv_len;
3543 try proto.appendFrame(&bbuf, alloc, .input, "echo quic-two-ok\n");
3544 b.out = bbuf.items;
3545 b.drain();
3546 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
3547 defer replica.deinit();
3548 _ = try quicPump(&srv, &both, 15000, &b, struct {
3549 fn f(t: *quic.TestClient) bool {
3550 return t.recv_len > 0 and findFrame(t.recv_buf[0..t.recv_len], .delta) != null;
3551 }
3552 }.f);
3553 try std.testing.expect(b.recv_len > before);
3554 }
3555
3556 test "Server: a QUIC client that stops reading is dropped by the cap, not tolerated" {
3557 const alloc = std.testing.allocator;
3558 var tmp = std.testing.tmpDir(.{});
3559 defer tmp.cleanup();
3560 var path_buf: [256]u8 = undefined;
3561 const dir_path = try tmp.dir.realpath(".", &path_buf);
3562 const sock_path = try std.fmt.allocPrint(alloc, "{s}/qcap.sock", .{dir_path});
3563 defer alloc.free(sock_path);
3564
3565 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
3566 const key: quic.Key = .{ .bytes = [_]u8{0x5D} ** quic.key_len };
3567 const q = try quicTestServer(&srv, key);
3568 defer q.l.deinit();
3569 defer srv.deinit();
3570
3571 var cl = try quic.TestClient.init(q.addr, key);
3572 defer cl.deinit();
3573 try cl.start();
3574
3575 var bbuf: std.ArrayList(u8) = .empty;
3576 defer bbuf.deinit(alloc);
3577 try attachOver(&cl, &bbuf, alloc);
3578
3579 var only = [_]*quic.TestClient{&cl};
3580 _ = try quicPump(&srv, &only, 8000, &cl, struct {
3581 fn f(t: *quic.TestClient) bool {
3582 return t.handshake_done and t.echoed > 0;
3583 }
3584 }.f);
3585 try std.testing.expect(srv.clients[0] != null);
3586
3587 // From here the client acknowledges nothing and reads nothing, so the
3588 // connection's egress ring fills and stays full. Everything the daemon
3589 // queues after that has nowhere to go but the slot's own queue, which is
3590 // what pending_cap is for. While the QUIC sink accepted every byte
3591 // unconditionally, this backlog lived inside the listener where no cap
3592 // could see it, and the daemon would have grown without bound rather
3593 // than dropping one hopeless client.
3594 srv.pending_cap = 512 * 1024;
3595 const chunk = try alloc.alloc(u8, 32 * 1024);
3596 defer alloc.free(chunk);
3597 @memset(chunk, 'q');
3598
3599 var i: usize = 0;
3600 while (i < 200 and srv.clients[0] != null) : (i += 1) {
3601 _ = srv.queueFrame(0, .snapshot, chunk);
3602 // The daemon's own machinery, not a hand-rolled loop: expiry,
3603 // egress and the flush that follows an ack all live in pumpOnce.
3604 _ = srv.pumpOnce(1) catch null;
3605 }
3606
3607 try std.testing.expect(srv.clients[0] == null);
3608 // The listener let go of the connection with the slot: a dropped client
3609 // must not leave its conn behind holding a ring.
3610 try std.testing.expect(q.l.pendingBytes(1) == 0);
3611 }
3612
3613 test "Server: drainPending waits for a QUIC client's acks, not just its queue" {
3614 const alloc = std.testing.allocator;
3615 var tmp = std.testing.tmpDir(.{});
3616 defer tmp.cleanup();
3617 var path_buf: [256]u8 = undefined;
3618 const dir_path = try tmp.dir.realpath(".", &path_buf);
3619 const sock_path = try std.fmt.allocPrint(alloc, "{s}/qdrain.sock", .{dir_path});
3620 defer alloc.free(sock_path);
3621
3622 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
3623 const key: quic.Key = .{ .bytes = [_]u8{0x6D} ** quic.key_len };
3624 const q = try quicTestServer(&srv, key);
3625 defer q.l.deinit();
3626 defer srv.deinit();
3627
3628 var cl = try quic.TestClient.init(q.addr, key);
3629 defer cl.deinit();
3630 try cl.start();
3631
3632 var bbuf: std.ArrayList(u8) = .empty;
3633 defer bbuf.deinit(alloc);
3634 try attachOver(&cl, &bbuf, alloc);
3635
3636 var only = [_]*quic.TestClient{&cl};
3637 _ = try quicPump(&srv, &only, 8000, &cl, struct {
3638 fn f(t: *quic.TestClient) bool {
3639 return t.handshake_done and t.echoed > 0;
3640 }
3641 }.f);
3642 try std.testing.expect(srv.clients[0] != null);
3643 const before = cl.echoed;
3644
3645 // Queue more than one ring's worth in one go, so the drain cannot
3646 // possibly finish by handing everything over once: some of it is still
3647 // in the slot's queue, and the rest is in ngtcp2's hands unacknowledged.
3648 // Both have to be waited out, which is the arm that used to return
3649 // immediately and call the job done.
3650 const payload = try alloc.alloc(u8, 700 * 1024);
3651 defer alloc.free(payload);
3652 @memset(payload, 'D');
3653 _ = srv.queueFrame(0, .snapshot, payload);
3654 try std.testing.expect(srv.clients[0] != null);
3655 try std.testing.expect(srv.clients[0].?.pending.items.len > 0);
3656
3657 // drainPending has to do the waiting itself. Nothing else is driving the
3658 // daemon here — the only thing servicing the listener for the rest of
3659 // this test is the loop inside drainPending.
3660 var done = false;
3661 const t0 = std.time.milliTimestamp();
3662 const th = try std.Thread.spawn(.{}, struct {
3663 fn f(client: *quic.TestClient, flag: *bool) void {
3664 // The peer: reads and acknowledges until the daemon says stop.
3665 while (!flag.*) {
3666 client.drain();
3667 var pfd = [_]std.posix.pollfd{
3668 .{ .fd = client.fd, .events = std.posix.POLL.IN, .revents = 0 },
3669 };
3670 if ((std.posix.poll(&pfd, 5) catch 0) > 0) client.readable();
3671 }
3672 }
3673 }.f, .{ &cl, &done });
3674 srv.drainPending(15_000);
3675 done = true;
3676 th.join();
3677 const elapsed = std.time.milliTimestamp() - t0;
3678
3679 // Everything owed actually left, and it left inside the budget.
3680 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.pending.items.len);
3681 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.sink.inFlight());
3682 try std.testing.expect(cl.echoed >= before + payload.len);
3683 try std.testing.expect(elapsed < 15_000);
3684 }
test/e2e.sh
Old New
@@ -14,6 +14,14 @@ D2PID=""
14 # the same path, so it cannot share the long-lived one. 14 # the same path, so it cannot share the long-lived one.
15 SOCK3="${TMPDIR:-/tmp}/muxd-e2e-restart-$$.sock" 15 SOCK3="${TMPDIR:-/tmp}/muxd-e2e-restart-$$.sock"
16 D3PID="" 16 D3PID=""
17 # Fourth daemon, for the M8 --quic scenario: it is the only one holding a UDP
18 # port, so it gets its own path rather than sharing the long-lived one.
19 SOCK4="${TMPDIR:-/tmp}/muxd-e2e-quic-$$.sock"
20 QKEY="${TMPDIR:-/tmp}/mux-e2e-key-$$"
21 D4PID=""
22 # A port out of the way of the ephemeral range, made per-run so two suites can
23 # overlap. Collisions surface as a loud bind failure, never as a silent pass.
24 QPORT=$(( 21000 + ($$ % 4000) ))
17 25
18 # Wait until PATTERN shows up in FILE (default 15s). Timing that keys off the 26 # Wait until PATTERN shows up in FILE (default 15s). Timing that keys off the
19 # session's own output instead of a fixed sleep: the marker is proof the 27 # session's own output instead of a fixed sleep: the marker is proof the
@@ -42,8 +50,11 @@ cleanup() {
42 # failing a suite that had actually passed. 50 # failing a suite that had actually passed.
43 [ -n "$D2PID" ] && kill "$D2PID" 2>/dev/null || true 51 [ -n "$D2PID" ] && kill "$D2PID" 2>/dev/null || true
44 [ -n "$D3PID" ] && kill "$D3PID" 2>/dev/null || true 52 [ -n "$D3PID" ] && kill "$D3PID" 2>/dev/null || true
45 rm -f "$SOCK" "$SOCK2" "$SOCK3" "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \ 53 [ -n "$D4PID" ] && kill "$D4PID" 2>/dev/null || true
46 "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" 54 rm -f "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \
55 "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \
56 "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \
57 "$OUT.q"
47 } 58 }
48 trap cleanup EXIT INT TERM 59 trap cleanup EXIT INT TERM
49 60
@@ -288,4 +299,102 @@ SNAPS_NEW=$("$MUXD" stats --sock "$SOCK3" | sed -n 's/.*snapshots=\([0-9]*\).*/\
288 } 299 }
289 rm -f "$OUT.m7b" 300 rm -f "$OUT.m7b"
290 301
302 # --- M8: the --quic flags. Every refusal must cost nothing — no session
303 # socket, no shell, no stack trace — and the accepted case must leave a
304 # daemon that is both listening on UDP and still an ordinary muxd.
305 #
306 # The client half of this (attaching over quic://) is Task 3; what is proven
307 # here is the daemon's side of the command line.
308
309 # A refusal that leaves a socket behind has already started a session, which
310 # is the failure this ordering exists to prevent.
311 refuse() {
312 _want="$1"; shift
313 set +e
314 "$MUXD" run --sock "$SOCK4" --shell /bin/sh "$@" > "$OUT.q" 2>&1
315 _rc=$?
316 set -e
317 [ "$_rc" -eq "$_want" ] || {
318 echo "e2e FAIL: muxd run $* exited $_rc (want $_want)"; cat "$OUT.q"; exit 1;
319 }
320 [ ! -e "$SOCK4" ] || {
321 echo "e2e FAIL: muxd run $* was refused but left $SOCK4 behind"; exit 1;
322 }
323 # One line, and not a stack trace: a Zig panic runs to dozens of lines
324 # and names a source file, which is what this is guarding against.
325 [ "$(wc -l < "$OUT.q")" -le 8 ] || {
326 echo "e2e FAIL: muxd run $* answered with more than a message:"; cat "$OUT.q"; exit 1;
327 }
328 }
329
330 head -c 32 /dev/urandom > "$QKEY"
331 chmod 600 "$QKEY"
332 cp "$QKEY" "$QKEY.bad"
333 chmod 644 "$QKEY.bad"
334
335 # Both or neither, and a usage mistake exits 2 like every other one.
336 refuse 2 --quic "127.0.0.1:$QPORT"
337 refuse 2 --key "$QKEY"
338 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 0
339 refuse 2 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms soon
340 # Refusals that are about the world rather than the spelling exit 1.
341 refuse 1 --quic "127.0.0.1" --key "$QKEY"
342 refuse 1 --quic "localhost:$QPORT" --key "$QKEY"
343 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.bad"
344 refuse 1 --quic "127.0.0.1:$QPORT" --key "$QKEY.missing"
345
346 # The accepted case.
347 "$MUXD" run --sock "$SOCK4" --shell /bin/sh \
348 --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000 &
349 D4PID=$!
350 i=0
351 while [ ! -S "$SOCK4" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
352 [ -S "$SOCK4" ] || { echo "e2e FAIL: --quic daemon never bound its session socket"; exit 1; }
353
354 # The UDP port is actually held. /proc/net/udp rather than ss or lsof: it is
355 # always there on the platform this daemon runs on, and needs no privileges.
356 # 127.0.0.1 is 0100007F in the little-endian hex the file uses.
357 QHEX=$(printf '0100007F:%04X' "$QPORT")
358 grep -qi " $QHEX " /proc/net/udp || {
359 echo "e2e FAIL: no UDP socket bound at 127.0.0.1:$QPORT ($QHEX)"
360 grep -i "0100007F" /proc/net/udp || true
361 exit 1
362 }
363
364 # A second daemon must NOT be able to take a share of that port. UDP with
365 # SO_REUSEADDR would let it bind alongside the first and the kernel would
366 # hand each datagram to one of them — two sessions splitting one port, with
367 # no error anywhere. This is the QUIC edition of the stale-socket story, and
368 # it needs two processes to test, which is why it lives here.
369 set +e
370 "$MUXD" run --sock "$SOCK4.second" --shell /bin/sh \
371 --quic "127.0.0.1:$QPORT" --key "$QKEY" > "$OUT.q" 2>&1
372 RC=$?
373 set -e
374 [ "$RC" -eq 1 ] || {
375 echo "e2e FAIL: a second daemon took udp $QPORT (exit $RC, want 1)"; cat "$OUT.q"; exit 1;
376 }
377 grep -q "already listening" "$OUT.q" || {
378 echo "e2e FAIL: second daemon refused, but not with the already-listening message:"
379 cat "$OUT.q"; exit 1;
380 }
381 [ ! -e "$SOCK4.second" ] || {
382 echo "e2e FAIL: refused second daemon left $SOCK4.second behind"; exit 1;
383 }
384
385 # ...and the daemon is still an ordinary daemon: the session runs and the
386 # unix-socket path is unaffected by the listener sharing its poll loop.
387 { printf 'printf "quic-%%s\\n" flags-ok\n'; sleep 2; printf '\034'; } | \
388 "$MUX" --sock "$SOCK4" > "$OUT.q"
389 grep -q "quic-flags-ok" "$OUT.q" || {
390 echo "e2e FAIL: --quic daemon did not serve an ordinary socket client"; cat "$OUT.q"; exit 1;
391 }
392 "$MUXD" dump --sock "$SOCK4" | grep -q "quic-flags-ok" || {
393 echo "e2e FAIL: --quic daemon's grid missing output"; exit 1;
394 }
395 kill -0 "$D4PID" || { echo "e2e FAIL: --quic daemon died"; exit 1; }
396 kill "$D4PID" 2>/dev/null || true
397 D4PID=""
398 rm -f "$OUT.q" "$QKEY" "$QKEY.bad"
399
291 echo "e2e OK" 400 echo "e2e OK"