a88c3250
feat: mux quic://host — the transport swap, third pass
a73x 2026-08-08 14:08
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -111,6 +111,18 @@ pub fn build(b: *std.Build) void { | |||
| 111 | server_mod.addImport("quic", quic_mod); | 111 | server_mod.addImport("quic", quic_mod); |
| 112 | server_mod.addImport("testtmp", testtmp_mod); | 112 | server_mod.addImport("testtmp", testtmp_mod); |
| 113 | 113 | ||
| 114 | // The client's QUIC transport. Imports the listener's module for the | ||
| 115 | // pieces both ends must agree on (the key, the egress ring's lifetime | ||
| 116 | // discipline, the PSK identity and ALPN) — duplicating those would make | ||
| 117 | // a handshake failure the first sign they had drifted. | ||
| 118 | const quic_client_mod = b.createModule(.{ | ||
| 119 | .root_source_file = b.path("src/quic_client.zig"), | ||
| 120 | .target = target, | ||
| 121 | .optimize = optimize, | ||
| 122 | .link_libc = true, | ||
| 123 | }); | ||
| 124 | quic_client_mod.addImport("quic", quic_mod); | ||
| 125 | |||
| 114 | const client_mod = b.createModule(.{ | 126 | const client_mod = b.createModule(.{ |
| 115 | .root_source_file = b.path("src/client.zig"), | 127 | .root_source_file = b.path("src/client.zig"), |
| 116 | .target = target, | 128 | .target = target, |
| @@ -120,6 +132,7 @@ pub fn build(b: *std.Build) void { | |||
| 120 | client_mod.addImport("engine", engine_mod); | 132 | client_mod.addImport("engine", engine_mod); |
| 121 | client_mod.addImport("protocol", protocol_mod); | 133 | client_mod.addImport("protocol", protocol_mod); |
| 122 | client_mod.addImport("testtmp", testtmp_mod); | 134 | client_mod.addImport("testtmp", testtmp_mod); |
| 135 | client_mod.addImport("quic_client", quic_client_mod); | ||
| 123 | 136 | ||
| 124 | const mux_mod = b.createModule(.{ | 137 | const mux_mod = b.createModule(.{ |
| 125 | .root_source_file = b.path("src/mux_main.zig"), | 138 | .root_source_file = b.path("src/mux_main.zig"), |
| @@ -167,6 +180,8 @@ pub fn build(b: *std.Build) void { | |||
| 167 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); | 180 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); |
| 168 | mux_exe.use_llvm = true; | 181 | mux_exe.use_llvm = true; |
| 169 | mux_exe.use_lld = true; | 182 | mux_exe.use_lld = true; |
| 183 | // The client speaks QUIC now, so it carries the stack too. | ||
| 184 | linkQuic(b, mux_exe, quic); | ||
| 170 | b.installArtifact(mux_exe); | 185 | b.installArtifact(mux_exe); |
| 171 | 186 | ||
| 172 | const test_step = b.step("test", "Run unit tests"); | 187 | const test_step = b.step("test", "Run unit tests"); |
| @@ -175,7 +190,7 @@ pub fn build(b: *std.Build) void { | |||
| 175 | // absence here was a live hazard recorded in decisions.md — muxd's | 190 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 176 | // entrypoint could grow tests that silently never ran, exactly as | 191 | // entrypoint could grow tests that silently never ran, exactly as |
| 177 | // mux_main.zig's five did before it was added. | 192 | // mux_main.zig's five did before it was added. |
| 178 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod }) |mod| { | 193 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod }) |mod| { |
| 179 | const t = b.addTest(.{ .root_module = mod }); | 194 | const t = b.addTest(.{ .root_module = mod }); |
| 180 | t.use_llvm = true; | 195 | t.use_llvm = true; |
| 181 | t.use_lld = true; | 196 | t.use_lld = true; |
| @@ -185,7 +200,8 @@ pub fn build(b: *std.Build) void { | |||
| 185 | // deps when they are absent. Without it the dependency reached only | 200 | // deps when they are absent. Without it the dependency reached only |
| 186 | // `muxd`, and a clean checkout running `make test` first would have | 201 | // `muxd`, and a clean checkout running `make test` first would have |
| 187 | // found no libraries and no explanation. | 202 | // found no libraries and no explanation. |
| 188 | if (mod == server_mod or mod == quic_mod or mod == exe_mod) linkQuic(b, t, quic); | 203 | if (mod == server_mod or mod == quic_mod or mod == exe_mod or |
| 204 | mod == client_mod or mod == mux_mod or mod == quic_client_mod) linkQuic(b, t, quic); | ||
| 189 | test_step.dependOn(&b.addRunArtifact(t).step); | 205 | test_step.dependOn(&b.addRunArtifact(t).step); |
| 190 | } | 206 | } |
| 191 | 207 | ||
src/client.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 proto = @import("protocol"); | 10 | const proto = @import("protocol"); |
| 11 | const TmpDir = @import("testtmp").TmpDir; | 11 | const TmpDir = @import("testtmp").TmpDir; |
| 12 | const quic_client = @import("quic_client"); | ||
| 12 | 13 | ||
| 13 | var winch_flag = std.atomic.Value(bool).init(false); | 14 | var winch_flag = std.atomic.Value(bool).init(false); |
| 14 | 15 | ||
| @@ -48,15 +49,68 @@ const reconnect_grace_ms: i64 = 5000; | |||
| 48 | /// struct is that it can be closed and opened again from the same recipe | 49 | /// struct is that it can be closed and opened again from the same recipe |
| 49 | /// (`sock_path` or `via`), which is what lets a session outlive its | 50 | /// (`sock_path` or `via`), which is what lets a session outlive its |
| 50 | /// transport instead of exiting with it. | 51 | /// transport instead of exiting with it. |
| 52 | /// How long a QUIC connection tolerates silence before declaring the peer | ||
| 53 | /// gone. Keepalives run at a third of this, so an idle session is never the | ||
| 54 | /// thing that trips it — only a peer that has actually stopped answering. | ||
| 55 | pub const quic_idle_ms_default: u32 = 15_000; | ||
| 56 | |||
| 57 | /// What a QUIC attach was asked for: where to dial and what key to prove | ||
| 58 | /// ourselves with. | ||
| 59 | pub const QuicTarget = struct { | ||
| 60 | host_port: []const u8, | ||
| 61 | key_path: []const u8, | ||
| 62 | idle_ms: u32 = quic_idle_ms_default, | ||
| 63 | }; | ||
| 64 | |||
| 51 | const Transport = struct { | 65 | const Transport = struct { |
| 52 | conn: Conn, | 66 | conn: Conn, |
| 53 | /// Only set for `--via`: the command whose stdio *is* the transport. | 67 | /// Only set for `--via`: the command whose stdio *is* the transport. |
| 54 | child: ?std.process.Child = null, | 68 | child: ?std.process.Child = null, |
| 69 | /// Only set for `quic://`: the connection that IS the transport. When | ||
| 70 | /// this is non-null `conn` holds the UDP socket in `.r` so the poll | ||
| 71 | /// path needs no special case, and `.w` is -1 because there is nothing | ||
| 72 | /// to write(2) to — bytes go through the stream layer. | ||
| 73 | quic: ?*quic_client.Client = null, | ||
| 74 | /// Bytes the QUIC ring would not take yet. Frames are appended whole and | ||
| 75 | /// handed over a prefix at a time, so a short accept can never split one | ||
| 76 | /// on the wire — it just means the rest waits here. Keystrokes and | ||
| 77 | /// attach frames are all this ever holds. | ||
| 78 | qout: std.ArrayList(u8) = .empty, | ||
| 79 | alloc: std.mem.Allocator = undefined, | ||
| 55 | 80 | ||
| 56 | /// Exactly one of `sock_path`/`via` is non-null; mux_main.zig enforces | 81 | /// Exactly one of `sock_path`/`via` is non-null; mux_main.zig enforces |
| 57 | /// that. Errors are returned rather than reported here — the caller | 82 | /// that. Errors are returned rather than reported here — the caller |
| 58 | /// knows which recipe it handed us and so which message fits. | 83 | /// knows which recipe it handed us and so which message fits. |
| 59 | fn open(alloc: std.mem.Allocator, sock_path: ?[]const u8, via: ?[]const u8) !Transport { | 84 | /// `carry` collects any non-abort bytes typed while a QUIC handshake is |
| 85 | /// in flight; null means drop them. See waitReady. | ||
| 86 | fn open( | ||
| 87 | alloc: std.mem.Allocator, | ||
| 88 | sock_path: ?[]const u8, | ||
| 89 | via: ?[]const u8, | ||
| 90 | quic: ?QuicTarget, | ||
| 91 | carry: ?*std.ArrayList(u8), | ||
| 92 | ) !Transport { | ||
| 93 | if (quic) |q| { | ||
| 94 | const key = try quic_client.Key.load(q.key_path); | ||
| 95 | const addr = try parseQuicAddr(q.host_port); | ||
| 96 | const cl = try quic_client.Client.connect(alloc, addr, key, q.idle_ms); | ||
| 97 | errdefer cl.deinit(); | ||
| 98 | // Wait for the handshake here rather than letting the caller | ||
| 99 | // discover it later. `connect` only creates state — the first | ||
| 100 | // flight has not been answered yet — so returning now would make | ||
| 101 | // "open succeeded" mean something different for QUIC than for | ||
| 102 | // every other transport, and the reconnect loop believes it: it | ||
| 103 | // would count a dead attempt as a live one, fire an attach into | ||
| 104 | // a connection that never completes, and do it again on the next | ||
| 105 | // pass. Against a daemon that was merely paused, that left a | ||
| 106 | // trail of half-open connections and duplicate attaches. | ||
| 107 | try waitReady(cl, q.idle_ms, alloc, carry); | ||
| 108 | return .{ | ||
| 109 | .conn = .{ .r = cl.pollFd(), .w = -1 }, | ||
| 110 | .quic = cl, | ||
| 111 | .alloc = alloc, | ||
| 112 | }; | ||
| 113 | } | ||
| 60 | if (via) |cmd| { | 114 | if (via) |cmd| { |
| 61 | var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc); | 115 | var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc); |
| 62 | child.stdin_behavior = .Pipe; | 116 | child.stdin_behavior = .Pipe; |
| @@ -94,12 +148,80 @@ const Transport = struct { | |||
| 94 | } | 148 | } |
| 95 | 149 | ||
| 96 | fn writeFrame(self: *Transport, t: proto.MsgType, payload: []const u8) !void { | 150 | fn writeFrame(self: *Transport, t: proto.MsgType, payload: []const u8) !void { |
| 151 | if (self.quic) |_| { | ||
| 152 | // Appended whole, handed over a prefix at a time. A frame can | ||
| 153 | // therefore never be split across a refusal — the ring takes | ||
| 154 | // what it takes and the remainder is offered again next pass. | ||
| 155 | try proto.appendFrame(&self.qout, self.alloc, t, payload); | ||
| 156 | self.flushQuic(); | ||
| 157 | return; | ||
| 158 | } | ||
| 97 | return proto.writeFrame(self.conn.w, t, payload); | 159 | return proto.writeFrame(self.conn.w, t, payload); |
| 98 | } | 160 | } |
| 99 | 161 | ||
| 162 | /// One service pass, called every loop iteration whether or not the | ||
| 163 | /// socket was readable. | ||
| 164 | /// | ||
| 165 | /// It has to be unconditional, and finding out why cost an e2e run: a | ||
| 166 | /// QUIC connection's timers are the ONLY thing that notices a peer which | ||
| 167 | /// has stopped answering. Servicing it just from the readable path means | ||
| 168 | /// a transport that goes quiet — a wrong key, a daemon that stopped — | ||
| 169 | /// never runs its idle timer, never declares itself dead, and the client | ||
| 170 | /// polls a corpse forever instead of exiting or reconnecting. | ||
| 171 | fn service(self: *Transport) void { | ||
| 172 | const cl = self.quic orelse return; | ||
| 173 | cl.pump(); | ||
| 174 | self.flushQuic(); | ||
| 175 | } | ||
| 176 | |||
| 177 | /// How long the caller may sleep before this transport needs attention. | ||
| 178 | /// Folds ngtcp2's next deadline in, so retransmits and idle timeouts | ||
| 179 | /// happen on time without a second timer. | ||
| 180 | fn timeoutMs(self: *Transport, cap_ms: i32) i32 { | ||
| 181 | const cl = self.quic orelse return cap_ms; | ||
| 182 | return cl.timeoutMs(cap_ms); | ||
| 183 | } | ||
| 184 | |||
| 185 | /// Whether frames can arrive from somewhere other than the descriptor. | ||
| 186 | /// True only for QUIC, where bytes already sit in a buffer and the next | ||
| 187 | /// whole frame may be there without the socket ever going readable. | ||
| 188 | fn buffersFrames(self: *const Transport) bool { | ||
| 189 | return self.quic != null; | ||
| 190 | } | ||
| 191 | |||
| 192 | /// Offer the outbound queue to the ring again. Called after every write | ||
| 193 | /// and on every service pass, because the room to accept comes from | ||
| 194 | /// acknowledgements, which arrive on their own schedule. | ||
| 195 | fn flushQuic(self: *Transport) void { | ||
| 196 | const cl = self.quic orelse return; | ||
| 197 | if (self.qout.items.len == 0) return; | ||
| 198 | const n = cl.send(self.qout.items); | ||
| 199 | if (n == 0) return; | ||
| 200 | const rest = self.qout.items.len - n; | ||
| 201 | std.mem.copyForwards(u8, self.qout.items[0..rest], self.qout.items[n..]); | ||
| 202 | self.qout.shrinkRetainingCapacity(rest); | ||
| 203 | } | ||
| 204 | |||
| 100 | /// The next whole frame, if there is one. See `Incoming` for why a | 205 | /// The next whole frame, if there is one. See `Incoming` for why a |
| 101 | /// missing frame is not automatically a dead transport. | 206 | /// missing frame is not automatically a dead transport. |
| 102 | fn readFrame(self: *Transport, alloc: std.mem.Allocator) !Incoming { | 207 | fn readFrame(self: *Transport, alloc: std.mem.Allocator) !Incoming { |
| 208 | if (self.quic) |cl| { | ||
| 209 | // Death is checked after the pump, so bytes that arrived in the | ||
| 210 | // same pass as the close are still delivered before the tear. | ||
| 211 | const buf = cl.inbound(); | ||
| 212 | if (buf.len < 5) return if (cl.dead) .closed else .incomplete; | ||
| 213 | const len = std.mem.readInt(u32, buf[1..5], .little); | ||
| 214 | if (len > proto.max_payload) return .closed; | ||
| 215 | if (buf.len < 5 + len) return if (cl.dead) .closed else .incomplete; | ||
| 216 | const payload = try alloc.alloc(u8, len); | ||
| 217 | @memcpy(payload, buf[5 .. 5 + len]); | ||
| 218 | const frame: proto.Frame = .{ | ||
| 219 | .type = @enumFromInt(buf[0]), | ||
| 220 | .payload = payload, | ||
| 221 | }; | ||
| 222 | cl.consume(5 + len); | ||
| 223 | return .{ .frame = frame }; | ||
| 224 | } | ||
| 103 | const frame = (proto.readFrame(alloc, self.conn.r) catch |err| switch (err) { | 225 | const frame = (proto.readFrame(alloc, self.conn.r) catch |err| switch (err) { |
| 104 | // Not a transport event, and it stays loud. | 226 | // Not a transport event, and it stays loud. |
| 105 | error.OutOfMemory => return err, | 227 | error.OutOfMemory => return err, |
| @@ -111,6 +233,15 @@ const Transport = struct { | |||
| 111 | fn close(self: *Transport) void { | 233 | fn close(self: *Transport) void { |
| 112 | if (self.conn.r == -1) return; // already released | 234 | if (self.conn.r == -1) return; // already released |
| 113 | defer self.conn = .{ .r = -1, .w = -1 }; | 235 | defer self.conn = .{ .r = -1, .w = -1 }; |
| 236 | if (self.quic) |cl| { | ||
| 237 | defer self.quic = null; | ||
| 238 | self.qout.deinit(self.alloc); | ||
| 239 | self.qout = .empty; | ||
| 240 | // Closes the UDP socket with it, so the fd this returns through | ||
| 241 | // `conn.r` must not be closed again below. | ||
| 242 | cl.deinit(); | ||
| 243 | return; | ||
| 244 | } | ||
| 114 | if (self.child) |*c| { | 245 | if (self.child) |*c| { |
| 115 | defer self.child = null; | 246 | defer self.child = null; |
| 116 | // Close stdin first so the command sees EOF and can wind down its | 247 | // Close stdin first so the command sees EOF and can wind down its |
| @@ -127,12 +258,153 @@ const Transport = struct { | |||
| 127 | } | 258 | } |
| 128 | }; | 259 | }; |
| 129 | 260 | ||
| 261 | /// Drive a fresh connection until it can carry bytes, or give up. | ||
| 262 | /// | ||
| 263 | /// Bounded by the same idle timeout the connection itself uses: a peer that | ||
| 264 | /// will not answer a handshake is the same peer that will not answer | ||
| 265 | /// anything, and one knob for both is one fewer thing to explain. An | ||
| 266 | /// unreachable UDP port usually produces no error at all — no ICMP, no | ||
| 267 | /// refusal — so this bound is the only thing that ends the wait. | ||
| 268 | fn waitReady( | ||
| 269 | cl: *quic_client.Client, | ||
| 270 | idle_ms: u32, | ||
| 271 | alloc: std.mem.Allocator, | ||
| 272 | carry: ?*std.ArrayList(u8), | ||
| 273 | ) !void { | ||
| 274 | const deadline = std.time.milliTimestamp() + idle_ms; | ||
| 275 | // A closed stdin stays readable forever, so once it reports EOF it has to | ||
| 276 | // stop being polled or this loop spins hot for the rest of the bound | ||
| 277 | // instead of waiting on the socket. Same hazard drainStdinForQuit | ||
| 278 | // documents, reached from the other direction. | ||
| 279 | var watch_stdin = true; | ||
| 280 | while (std.time.milliTimestamp() < deadline) { | ||
| 281 | cl.pump(); | ||
| 282 | if (cl.isReady()) return; | ||
| 283 | if (cl.dead) return error.QuicHandshakeFailed; | ||
| 284 | // stdin is watched alongside the socket, and it has to be. This wait | ||
| 285 | // runs INSIDE Transport.open, after drainStdinForQuit has already | ||
| 286 | // returned, so for its whole length nothing else is looking for the | ||
| 287 | // abort key — and during a reconnect the terminal is in raw mode, so | ||
| 288 | // Ctrl-C is just a byte and Ctrl-\ is the only way out. Watching only | ||
| 289 | // the socket left the user with no way to stop for as long as the | ||
| 290 | // handshake bound allows: measured at 14.6s on the default idle | ||
| 291 | // timeout, against an endpoint that was never going to answer. M7's | ||
| 292 | // uncapped retry loop is justified by the user having an abort key, | ||
| 293 | // so an abort key that stops working mid-handshake takes the | ||
| 294 | // justification with it. | ||
| 295 | var fds = [_]std.posix.pollfd{ | ||
| 296 | .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 297 | .{ | ||
| 298 | .fd = if (watch_stdin) std.posix.STDIN_FILENO else -1, | ||
| 299 | .events = std.posix.POLL.IN, | ||
| 300 | .revents = 0, | ||
| 301 | }, | ||
| 302 | }; | ||
| 303 | _ = std.posix.poll(&fds, cl.timeoutMs(50)) catch break; | ||
| 304 | if (fds[1].revents != 0) { | ||
| 305 | var buf: [1024]u8 = undefined; | ||
| 306 | const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch 0; | ||
| 307 | if (n == 0) watch_stdin = false; | ||
| 308 | if (n > 0) { | ||
| 309 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) return error.UserAbort; | ||
| 310 | // Not the abort key. Whether these bytes are kept or dropped | ||
| 311 | // is the caller's policy, not this function's: on a first | ||
| 312 | // attach they are the user's first keystrokes and are owed to | ||
| 313 | // the shell, while during a reconnect input is dropped by | ||
| 314 | // long-standing policy — replaying a burst of stale | ||
| 315 | // keystrokes on resume is worse than losing them. | ||
| 316 | if (carry) |q| q.appendSlice(alloc, buf[0..n]) catch {}; | ||
| 317 | } | ||
| 318 | } | ||
| 319 | } | ||
| 320 | cl.pump(); | ||
| 321 | if (cl.isReady()) return; | ||
| 322 | return error.QuicHandshakeFailed; | ||
| 323 | } | ||
| 324 | |||
| 325 | /// `HOST:PORT` for a `quic://` target. Literal addresses only on the muxd | ||
| 326 | /// side because a bind address that resolves to several is a question; here | ||
| 327 | /// a NAME is exactly what a user types, so this one does resolve. | ||
| 328 | fn parseQuicAddr(host_port: []const u8) !std.net.Address { | ||
| 329 | const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse | ||
| 330 | return error.MalformedAddress; | ||
| 331 | var host = host_port[0..colon]; | ||
| 332 | const port_s = host_port[colon + 1 ..]; | ||
| 333 | // `[::1]:4433` — brackets are how an IPv6 literal says where it stops. | ||
| 334 | if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') { | ||
| 335 | host = host[1 .. host.len - 1]; | ||
| 336 | } else if (std.mem.indexOfScalar(u8, host, ':') != null) { | ||
| 337 | // Unbracketed and full of colons: an IPv6 literal missing its | ||
| 338 | // brackets, which would otherwise have its last group taken as a | ||
| 339 | // port. Refused rather than guessed at. | ||
| 340 | return error.MalformedAddress; | ||
| 341 | } | ||
| 342 | if (host.len == 0) return error.MalformedAddress; | ||
| 343 | const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress; | ||
| 344 | if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {} | ||
| 345 | // Not a literal: resolve it. A remote host is normally a name. | ||
| 346 | const list = try std.net.getAddressList(std.heap.page_allocator, host, port); | ||
| 347 | defer list.deinit(); | ||
| 348 | if (list.addrs.len == 0) return error.UnknownHostName; | ||
| 349 | return list.addrs[0]; | ||
| 350 | } | ||
| 351 | |||
| 130 | /// Attach over a local socket (`sock_path`) or over an arbitrary command's | 352 | /// Attach over a local socket (`sock_path`) or over an arbitrary command's |
| 131 | /// stdio (`via`, typically `ssh host muxd proxy ...`). Exactly one is | 353 | /// stdio (`via`, typically `ssh host muxd proxy ...`). Exactly one is |
| 132 | /// non-null; mux_main.zig enforces that. | 354 | /// non-null; mux_main.zig enforces that. |
| 133 | pub fn attach(alloc: std.mem.Allocator, sock_path: ?[]const u8, via: ?[]const u8) !u8 { | 355 | pub fn attach( |
| 134 | var transport = Transport.open(alloc, sock_path, via) catch { | 356 | alloc: std.mem.Allocator, |
| 135 | if (via) |cmd| { | 357 | sock_path: ?[]const u8, |
| 358 | via: ?[]const u8, | ||
| 359 | quic: ?QuicTarget, | ||
| 360 | ) !u8 { | ||
| 361 | // Anything typed while the first handshake is in flight belongs to the | ||
| 362 | // shell, so it is held here rather than dropped — and handed over once | ||
| 363 | // there is a session to hand it to. | ||
| 364 | var carry: std.ArrayList(u8) = .empty; | ||
| 365 | defer carry.deinit(alloc); | ||
| 366 | |||
| 367 | var transport = Transport.open(alloc, sock_path, via, quic, &carry) catch |err| { | ||
| 368 | if (quic) |q| { | ||
| 369 | // A key the daemon would also have refused, said in the same | ||
| 370 | // words, because the user's mistake is the same one. | ||
| 371 | switch (err) { | ||
| 372 | error.KeyFileMissing => std.debug.print( | ||
| 373 | "mux: no such key file: {s}\n", | ||
| 374 | .{q.key_path}, | ||
| 375 | ), | ||
| 376 | error.KeyFilePermissive => std.debug.print( | ||
| 377 | "mux: {s} is readable by group or other; chmod 600 it\n", | ||
| 378 | .{q.key_path}, | ||
| 379 | ), | ||
| 380 | error.KeyFileMalformed => std.debug.print( | ||
| 381 | "mux: {s} is not a key: want 32 raw bytes or 64 hex characters\n", | ||
| 382 | .{q.key_path}, | ||
| 383 | ), | ||
| 384 | error.MalformedAddress, error.UnknownHostName => std.debug.print( | ||
| 385 | "mux: cannot resolve quic://{s}\n", | ||
| 386 | .{q.host_port}, | ||
| 387 | ), | ||
| 388 | // The handshake is also where a wrong key lands: an external | ||
| 389 | // PSK that does not match produces no distinguishable | ||
| 390 | // rejection, just a handshake that never completes. Saying | ||
| 391 | // both is more honest than guessing which it was. | ||
| 392 | error.QuicHandshakeFailed => std.debug.print( | ||
| 393 | "mux: quic://{s} did not answer (wrong key, or no muxd --quic there)\n", | ||
| 394 | .{q.host_port}, | ||
| 395 | ), | ||
| 396 | // The user pressed Ctrl-\ while we were still dialling. | ||
| 397 | // Nothing failed, so nothing is reported as a failure. | ||
| 398 | error.UserAbort => { | ||
| 399 | std.debug.print("mux: aborted before attaching\n", .{}); | ||
| 400 | return 0; | ||
| 401 | }, | ||
| 402 | else => std.debug.print( | ||
| 403 | "mux: cannot reach quic://{s}: {s}\n", | ||
| 404 | .{ q.host_port, @errorName(err) }, | ||
| 405 | ), | ||
| 406 | } | ||
| 407 | } else if (via) |cmd| { | ||
| 136 | std.debug.print("mux: cannot start --via command: {s}\n", .{cmd}); | 408 | std.debug.print("mux: cannot start --via command: {s}\n", .{cmd}); |
| 137 | } else { | 409 | } else { |
| 138 | std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path.?}); | 410 | std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path.?}); |
| @@ -140,7 +412,7 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: ?[]const u8, via: ?[]const u8 | |||
| 140 | return 1; | 412 | return 1; |
| 141 | }; | 413 | }; |
| 142 | defer transport.close(); | 414 | defer transport.close(); |
| 143 | return session(alloc, &transport, sock_path, via); | 415 | return session(alloc, &transport, sock_path, via, quic, &carry); |
| 144 | } | 416 | } |
| 145 | 417 | ||
| 146 | /// `sock_path` and `via` are the recipe this session's transport was built | 418 | /// `sock_path` and `via` are the recipe this session's transport was built |
| @@ -152,6 +424,10 @@ fn session( | |||
| 152 | transport: *Transport, | 424 | transport: *Transport, |
| 153 | sock_path: ?[]const u8, | 425 | sock_path: ?[]const u8, |
| 154 | via: ?[]const u8, | 426 | via: ?[]const u8, |
| 427 | quic: ?QuicTarget, | ||
| 428 | /// Keystrokes that arrived during the opening handshake, owed to the | ||
| 429 | /// shell as soon as there is an attach to send them after. | ||
| 430 | carry: *std.ArrayList(u8), | ||
| 155 | ) !u8 { | 431 | ) !u8 { |
| 156 | // A daemon that dies mid-write must surface as an error return from | 432 | // A daemon that dies mid-write must surface as an error return from |
| 157 | // write(), not a fatal SIGPIPE. Zig's start.zig already installs a noop | 433 | // write(), not a fatal SIGPIPE. Zig's start.zig already installs a noop |
| @@ -222,6 +498,18 @@ fn session( | |||
| 222 | return 1; | 498 | return 1; |
| 223 | }; | 499 | }; |
| 224 | 500 | ||
| 501 | // Whatever was typed while the handshake was in flight, in the order it | ||
| 502 | // was typed, now that there is somewhere for it to go. Dropping it would | ||
| 503 | // silently eat the first command of a piped session, which is how a test | ||
| 504 | // harness types. | ||
| 505 | if (carry.items.len > 0) { | ||
| 506 | transport.writeFrame(.input, carry.items) catch { | ||
| 507 | exit_msg = "mux: connection to muxd lost"; | ||
| 508 | return 1; | ||
| 509 | }; | ||
| 510 | carry.clearRetainingCapacity(); | ||
| 511 | } | ||
| 512 | |||
| 225 | var stdin_open = true; | 513 | var stdin_open = true; |
| 226 | // Scroll mode: 0 = live; N = viewing the page N screenfuls above live. | 514 | // Scroll mode: 0 = live; N = viewing the page N screenfuls above live. |
| 227 | var scroll_pages: u32 = 0; | 515 | var scroll_pages: u32 = 0; |
| @@ -285,6 +573,7 @@ fn session( | |||
| 285 | transport, | 573 | transport, |
| 286 | sock_path, | 574 | sock_path, |
| 287 | via, | 575 | via, |
| 576 | quic, | ||
| 288 | size, | 577 | size, |
| 289 | last_seq, | 578 | last_seq, |
| 290 | session_epoch, | 579 | session_epoch, |
| @@ -328,9 +617,16 @@ fn session( | |||
| 328 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | 617 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, |
| 329 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | 618 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, |
| 330 | }; | 619 | }; |
| 331 | _ = try std.posix.poll(&fds, 100); | 620 | _ = try std.posix.poll(&fds, transport.timeoutMs(100)); |
| 332 | 621 | transport.service(); | |
| 333 | if (fds[0].revents != 0) { | 622 | |
| 623 | // Labelled, because "no whole frame yet" must leave the REST of this | ||
| 624 | // iteration running. Everything below — the detach chord, keystrokes, | ||
| 625 | // resize — lives in the same pass, so jumping straight back to the | ||
| 626 | // poll would drop the user's input on the floor. It never came up | ||
| 627 | // over a socket, where a readable descriptor always carried a frame; | ||
| 628 | // over QUIC it is the ordinary case, and it cost an e2e run to find. | ||
| 629 | if (fds[0].revents != 0 or transport.buffersFrames()) frames: { | ||
| 334 | // A clean EOF and a frame torn in half are the same event to us: | 630 | // A clean EOF and a frame torn in half are the same event to us: |
| 335 | // the transport is gone. Over ssh the tear is the ordinary case — | 631 | // the transport is gone. Over ssh the tear is the ordinary case — |
| 336 | // the channel drops mid-frame — so it has to read as a message | 632 | // the channel drops mid-frame — so it has to read as a message |
| @@ -339,8 +635,8 @@ fn session( | |||
| 339 | const frame = switch (try transport.readFrame(alloc)) { | 635 | const frame = switch (try transport.readFrame(alloc)) { |
| 340 | .frame => |f| f, | 636 | .frame => |f| f, |
| 341 | // Bytes arrived that do not complete a frame. Nothing is | 637 | // Bytes arrived that do not complete a frame. Nothing is |
| 342 | // wrong; go back to the poll and wait for the rest. | 638 | // wrong; skip the frame handling and carry on. |
| 343 | .incomplete => continue, | 639 | .incomplete => break :frames, |
| 344 | .closed => { | 640 | .closed => { |
| 345 | // Not fatal any more: rebuild the transport and | 641 | // Not fatal any more: rebuild the transport and |
| 346 | // re-attach with what we hold. The replica stays exactly | 642 | // re-attach with what we hold. The replica stays exactly |
| @@ -660,6 +956,7 @@ fn reconnect( | |||
| 660 | transport: *Transport, | 956 | transport: *Transport, |
| 661 | sock_path: ?[]const u8, | 957 | sock_path: ?[]const u8, |
| 662 | via: ?[]const u8, | 958 | via: ?[]const u8, |
| 959 | quic: ?QuicTarget, | ||
| 663 | size: proto.Size, | 960 | size: proto.Size, |
| 664 | last_seq: u64, | 961 | last_seq: u64, |
| 665 | session_epoch: u64, | 962 | session_epoch: u64, |
| @@ -693,7 +990,12 @@ fn reconnect( | |||
| 693 | if (drainStdinForQuit(stdin_fd, backoff_ms)) return false; | 990 | if (drainStdinForQuit(stdin_fd, backoff_ms)) return false; |
| 694 | backoff_ms = if (backoff_ms == 0) 200 else @min(backoff_ms * 2, 2000); | 991 | backoff_ms = if (backoff_ms == 0) 200 else @min(backoff_ms * 2, 2000); |
| 695 | 992 | ||
| 696 | var fresh = Transport.open(alloc, sock_path, via) catch continue; | 993 | var fresh = Transport.open(alloc, sock_path, via, quic, null) catch |err| { |
| 994 | // Ctrl-\ during the handshake is the same answer as Ctrl-\ | ||
| 995 | // during the backoff: the user is done waiting. | ||
| 996 | if (err == error.UserAbort) return false; | ||
| 997 | continue; | ||
| 998 | }; | ||
| 697 | fresh.writeFrame( | 999 | fresh.writeFrame( |
| 698 | .attach, | 1000 | .attach, |
| 699 | &proto.encodeAttach(size.cols, size.rows, last_seq, session_epoch), | 1001 | &proto.encodeAttach(size.cols, size.rows, last_seq, session_epoch), |
| @@ -744,7 +1046,7 @@ test "Transport.close is idempotent: the abort path closes what reconnect alread | |||
| 744 | var listener = try addr.listen(.{}); | 1046 | var listener = try addr.listen(.{}); |
| 745 | defer listener.deinit(); | 1047 | defer listener.deinit(); |
| 746 | 1048 | ||
| 747 | var transport = try Transport.open(alloc, sock_path, null); | 1049 | var transport = try Transport.open(alloc, sock_path, null, null, null); |
| 748 | 1050 | ||
| 749 | // reconnect() closes the dead transport at entry; if the user then aborts, | 1051 | // reconnect() closes the dead transport at entry; if the user then aborts, |
| 750 | // attach()'s `defer transport.close()` closes it a second time. Without a | 1052 | // attach()'s `defer transport.close()` closes it a second time. Without a |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -6,8 +6,11 @@ const std = @import("std"); | |||
| 6 | const client = @import("client"); | 6 | const client = @import("client"); |
| 7 | 7 | ||
| 8 | const usage = | 8 | const usage = |
| 9 | \\usage: mux [HOST | --sock PATH | --via CMD] | 9 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST:PORT] |
| 10 | \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH) | 10 | \\ HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH) |
| 11 | \\ quic://HOST:PORT needs --key FILE (or MUX_KEY_FILE); muxd must be | ||
| 12 | \\ running with a matching --quic and --key | ||
| 13 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | ||
| 11 | \\ | 14 | \\ |
| 12 | ; | 15 | ; |
| 13 | 16 | ||
| @@ -20,16 +23,31 @@ const ParseResult = union(enum) { | |||
| 20 | /// A bare hostname: the ssh recipe is built from it in main, where there | 23 | /// A bare hostname: the ssh recipe is built from it in main, where there |
| 21 | /// is an allocator to build it with. | 24 | /// is an allocator to build it with. |
| 22 | host: []const u8, | 25 | host: []const u8, |
| 26 | /// A direct QUIC attach. The key is resolved in main, where the | ||
| 27 | /// environment can be consulted. | ||
| 28 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32 }, | ||
| 29 | /// `quic://` named without a key anywhere. Its own result rather than a | ||
| 30 | /// usage error: nothing is misspelled, something is missing, and the | ||
| 31 | /// message that helps says which. | ||
| 32 | quic_without_key, | ||
| 23 | /// More than one transport named — a request that cannot be honoured | 33 | /// More than one transport named — a request that cannot be honoured |
| 24 | /// rather than one to reconcile. | 34 | /// rather than one to reconcile. |
| 25 | conflict, | 35 | conflict, |
| 26 | usage_error, | 36 | usage_error, |
| 27 | }; | 37 | }; |
| 28 | 38 | ||
| 29 | fn parseArgs(args: []const [:0]const u8) ParseResult { | 39 | /// The environment variable consulted when `--key` is absent. Named rather |
| 40 | /// than inlined because the parse cannot read it — the parse stays pure so | ||
| 41 | /// it stays testable — and `main` has to use exactly the same name. | ||
| 42 | pub const key_env = "MUX_KEY_FILE"; | ||
| 43 | |||
| 44 | fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | ||
| 30 | var sock: ?[]const u8 = null; | 45 | var sock: ?[]const u8 = null; |
| 31 | var via: ?[]const u8 = null; | 46 | var via: ?[]const u8 = null; |
| 32 | var host: ?[]const u8 = null; | 47 | var host: ?[]const u8 = null; |
| 48 | var quic: ?[]const u8 = null; | ||
| 49 | var key: ?[]const u8 = null; | ||
| 50 | var idle_ms: u32 = client.quic_idle_ms_default; | ||
| 33 | 51 | ||
| 34 | var i: usize = 1; | 52 | var i: usize = 1; |
| 35 | while (i < args.len) : (i += 1) { | 53 | while (i < args.len) : (i += 1) { |
| @@ -42,6 +60,22 @@ fn parseArgs(args: []const [:0]const u8) ParseResult { | |||
| 42 | i += 1; | 60 | i += 1; |
| 43 | if (via != null) return .conflict; | 61 | if (via != null) return .conflict; |
| 44 | via = args[i]; | 62 | via = args[i]; |
| 63 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 64 | i += 1; | ||
| 65 | key = args[i]; | ||
| 66 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 67 | i += 1; | ||
| 68 | const n = std.fmt.parseInt(u32, args[i], 10) catch return .usage_error; | ||
| 69 | // Zero means "no idle timeout" to ngtcp2, the inverse of what | ||
| 70 | // anyone typing a timeout of zero is asking for. | ||
| 71 | if (n == 0) return .usage_error; | ||
| 72 | idle_ms = n; | ||
| 73 | } else if (std.mem.startsWith(u8, a, "quic://")) { | ||
| 74 | // Counted with the others, so `mux quic://a:1 --sock /x` is the | ||
| 75 | // same conflict as naming any other two transports. | ||
| 76 | if (quic != null) return .conflict; | ||
| 77 | quic = a["quic://".len..]; | ||
| 78 | if (quic.?.len == 0) return .usage_error; | ||
| 45 | } else if (a.len > 0 and a[0] != '-') { | 79 | } else if (a.len > 0 and a[0] != '-') { |
| 46 | // A bare word is a host to hop to. Two of them is as ambiguous | 80 | // A bare word is a host to hop to. Two of them is as ambiguous |
| 47 | // as naming two transports, so it lands in the same place. | 81 | // as naming two transports, so it lands in the same place. |
| @@ -54,11 +88,24 @@ fn parseArgs(args: []const [:0]const u8) ParseResult { | |||
| 54 | } | 88 | } |
| 55 | } | 89 | } |
| 56 | 90 | ||
| 57 | // Every pairing of the three is two transports for one session. | 91 | // Every pairing of the four is two transports for one session. |
| 58 | const named: u8 = @as(u8, @intFromBool(sock != null)) + | 92 | const named: u8 = @as(u8, @intFromBool(sock != null)) + |
| 59 | @intFromBool(via != null) + @intFromBool(host != null); | 93 | @intFromBool(via != null) + @intFromBool(host != null) + |
| 94 | @intFromBool(quic != null); | ||
| 60 | if (named > 1) return .conflict; | 95 | if (named > 1) return .conflict; |
| 61 | 96 | ||
| 97 | if (quic) |hp| { | ||
| 98 | // --key wins over the environment; the environment exists so a | ||
| 99 | // shell can set it once rather than repeating it per invocation. | ||
| 100 | const k = key orelse env_key orelse return .quic_without_key; | ||
| 101 | if (k.len == 0) return .quic_without_key; | ||
| 102 | return .{ .quic = .{ .host_port = hp, .key = k, .idle_ms = idle_ms } }; | ||
| 103 | } | ||
| 104 | // A key with no quic:// has nothing to authenticate and is ignored | ||
| 105 | // rather than refused: unlike muxd, where --key without --quic means a | ||
| 106 | // listener was meant, here it is one env var away from being set for | ||
| 107 | // every invocation in a shell, and refusing `mux --sock ...` because | ||
| 108 | // MUX_KEY_FILE happens to be exported would be absurd. | ||
| 62 | if (host) |h| return .{ .host = h }; | 109 | if (host) |h| return .{ .host = h }; |
| 63 | return .{ .attach = .{ .sock = sock, .via = via } }; | 110 | return .{ .attach = .{ .sock = sock, .via = via } }; |
| 64 | } | 111 | } |
| @@ -71,25 +118,40 @@ pub fn main() !u8 { | |||
| 71 | const args = try std.process.argsAlloc(alloc); | 118 | const args = try std.process.argsAlloc(alloc); |
| 72 | defer std.process.argsFree(alloc, args); | 119 | defer std.process.argsFree(alloc, args); |
| 73 | 120 | ||
| 74 | const parsed = parseArgs(args); | 121 | const parsed = parseArgs(args, std.posix.getenv(key_env)); |
| 75 | switch (parsed) { | 122 | switch (parsed) { |
| 76 | .usage_error => { | 123 | .usage_error => { |
| 77 | std.debug.print("{s}", .{usage}); | 124 | std.debug.print("{s}", .{usage}); |
| 78 | return 2; | 125 | return 2; |
| 79 | }, | 126 | }, |
| 80 | .conflict => { | 127 | .conflict => { |
| 81 | std.debug.print("mux: name one transport: HOST, --sock or --via\n{s}", .{usage}); | 128 | std.debug.print( |
| 129 | "mux: name one transport: HOST, --sock, --via or quic://\n{s}", | ||
| 130 | .{usage}, | ||
| 131 | ); | ||
| 132 | return 2; | ||
| 133 | }, | ||
| 134 | .quic_without_key => { | ||
| 135 | std.debug.print( | ||
| 136 | "mux: quic:// needs --key FILE (or {s}); there is no unauthenticated mode\n", | ||
| 137 | .{key_env}, | ||
| 138 | ); | ||
| 82 | return 2; | 139 | return 2; |
| 83 | }, | 140 | }, |
| 141 | .quic => |q| return client.attach(alloc, null, null, .{ | ||
| 142 | .host_port = q.host_port, | ||
| 143 | .key_path = q.key.?, | ||
| 144 | .idle_ms = q.idle_ms, | ||
| 145 | }), | ||
| 84 | .host => |h| { | 146 | .host => |h| { |
| 85 | // muxd on the far side exposes the session over its stdio; ssh | 147 | // muxd on the far side exposes the session over its stdio; ssh |
| 86 | // carries the bytes and nothing here knows the difference. | 148 | // carries the bytes and nothing here knows the difference. |
| 87 | const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd proxy", .{h}); | 149 | const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd proxy", .{h}); |
| 88 | defer alloc.free(cmd); | 150 | defer alloc.free(cmd); |
| 89 | return client.attach(alloc, null, cmd); | 151 | return client.attach(alloc, null, cmd, null); |
| 90 | }, | 152 | }, |
| 91 | .attach => |t| { | 153 | .attach => |t| { |
| 92 | if (t.via) |cmd| return client.attach(alloc, null, cmd); | 154 | if (t.via) |cmd| return client.attach(alloc, null, cmd, null); |
| 93 | const sock_path = if (t.sock) |s| | 155 | const sock_path = if (t.sock) |s| |
| 94 | try alloc.dupe(u8, s) | 156 | try alloc.dupe(u8, s) |
| 95 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| | 157 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| |
| @@ -97,7 +159,7 @@ pub fn main() !u8 { | |||
| 97 | else | 159 | else |
| 98 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | 160 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); |
| 99 | defer alloc.free(sock_path); | 161 | defer alloc.free(sock_path); |
| 100 | return client.attach(alloc, sock_path, null); | 162 | return client.attach(alloc, sock_path, null, null); |
| 101 | }, | 163 | }, |
| 102 | } | 164 | } |
| 103 | } | 165 | } |
| @@ -105,7 +167,12 @@ pub fn main() !u8 { | |||
| 105 | /// Test helper: parseArgs takes what argsAlloc produces, so the tests have to | 167 | /// Test helper: parseArgs takes what argsAlloc produces, so the tests have to |
| 106 | /// speak the same type — a slice of sentinel-terminated strings. | 168 | /// speak the same type — a slice of sentinel-terminated strings. |
| 107 | fn parse(comptime argv: []const [:0]const u8) ParseResult { | 169 | fn parse(comptime argv: []const [:0]const u8) ParseResult { |
| 108 | return parseArgs(argv); | 170 | return parseArgs(argv, null); |
| 171 | } | ||
| 172 | |||
| 173 | /// The same, with `MUX_KEY_FILE` set to `env`. | ||
| 174 | fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseResult { | ||
| 175 | return parseArgs(argv, env); | ||
| 109 | } | 176 | } |
| 110 | 177 | ||
| 111 | test "parseArgs: no arguments means the default local socket" { | 178 | test "parseArgs: no arguments means the default local socket" { |
| @@ -157,3 +224,55 @@ test "parseArgs: unknown flags and valueless flags are usage errors" { | |||
| 157 | try std.testing.expect(parse(&.{ "mux", "--sock" }) == .usage_error); | 224 | try std.testing.expect(parse(&.{ "mux", "--sock" }) == .usage_error); |
| 158 | try std.testing.expect(parse(&.{ "mux", "--via" }) == .usage_error); | 225 | try std.testing.expect(parse(&.{ "mux", "--via" }) == .usage_error); |
| 159 | } | 226 | } |
| 227 | |||
| 228 | test "parseArgs: quic:// is a transport like any other" { | ||
| 229 | const q = parse(&.{ "mux", "quic://box:4433", "--key", "/k" }); | ||
| 230 | try std.testing.expect(q == .quic); | ||
| 231 | try std.testing.expectEqualStrings("box:4433", q.quic.host_port); | ||
| 232 | try std.testing.expectEqualStrings("/k", q.quic.key.?); | ||
| 233 | try std.testing.expectEqual(client.quic_idle_ms_default, q.quic.idle_ms); | ||
| 234 | |||
| 235 | // Counted with the rest: naming it alongside another transport is the | ||
| 236 | // same ambiguity as any other pairing, whichever order they arrive in. | ||
| 237 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" }) == .conflict); | ||
| 238 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" }) == .conflict); | ||
| 239 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" }) == .conflict); | ||
| 240 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "vm1" }) == .conflict); | ||
| 241 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "quic://b:2", "--key", "/k" }) == .conflict); | ||
| 242 | |||
| 243 | // The scheme with nothing after it names no host. | ||
| 244 | try std.testing.expect(parse(&.{ "mux", "quic://", "--key", "/k" }) == .usage_error); | ||
| 245 | } | ||
| 246 | |||
| 247 | test "parseArgs: a quic attach without a key is refused, by whichever route" { | ||
| 248 | // No --key and no environment. | ||
| 249 | try std.testing.expect(parse(&.{ "mux", "quic://a:1" }) == .quic_without_key); | ||
| 250 | // An environment variable set to nothing is not a key. | ||
| 251 | try std.testing.expect(parseEnv(&.{ "mux", "quic://a:1" }, "") == .quic_without_key); | ||
| 252 | |||
| 253 | // The environment supplies it when the flag does not... | ||
| 254 | const e = parseEnv(&.{ "mux", "quic://a:1" }, "/env.key"); | ||
| 255 | try std.testing.expect(e == .quic); | ||
| 256 | try std.testing.expectEqualStrings("/env.key", e.quic.key.?); | ||
| 257 | |||
| 258 | // ...and the flag wins when both are there, because it is the more | ||
| 259 | // specific statement of intent. | ||
| 260 | const both = parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key"); | ||
| 261 | try std.testing.expectEqualStrings("/flag.key", both.quic.key.?); | ||
| 262 | |||
| 263 | // A key with no quic:// is ignored rather than refused: MUX_KEY_FILE | ||
| 264 | // exported in a shell must not break an ordinary local attach. | ||
| 265 | try std.testing.expect(parseEnv(&.{"mux"}, "/env.key") == .attach); | ||
| 266 | try std.testing.expect(parse(&.{ "mux", "--key", "/k" }) == .attach); | ||
| 267 | try std.testing.expect(parse(&.{ "mux", "--key", "/k", "vm1" }) == .host); | ||
| 268 | } | ||
| 269 | |||
| 270 | test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" { | ||
| 271 | const t = parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" }); | ||
| 272 | try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms); | ||
| 273 | |||
| 274 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); | ||
| 275 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); | ||
| 276 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); | ||
| 277 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms" }) == .usage_error); | ||
| 278 | } | ||
src/quic_client.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,483 @@ | |||
| 1 | //! mux's QUIC client transport: one UDP socket, one connection, one | ||
| 2 | //! bidirectional stream of opaque bytes. | ||
| 3 | //! | ||
| 4 | //! The mirror of `quic_server.zig` and held to the same discipline: it knows | ||
| 5 | //! NOTHING about the frame protocol it carries. It hands the caller a byte | ||
| 6 | //! stream and takes bytes back; `client.zig` does the framing. That is what | ||
| 7 | //! keeps "the transport is a swap" a checkable claim rather than a slogan, | ||
| 8 | //! and it is why there is no `proto` import here. | ||
| 9 | //! | ||
| 10 | //! Three things in here are carried over from the daemon's side rather than | ||
| 11 | //! rediscovered, because they were paid for once already: | ||
| 12 | //! | ||
| 13 | //! 1. **ngtcp2 does not copy stream payload.** It keeps the vector it is | ||
| 14 | //! handed and re-reads those bytes to retransmit, so outbound bytes | ||
| 15 | //! must not move or be reused until the peer acknowledges them. Same | ||
| 16 | //! `Egress` ring as the listener, same invariant, same reason. | ||
| 17 | //! 2. **A blocked stream is not a dead one.** NGTCP2_ERR_STREAM_DATA_BLOCKED | ||
| 18 | //! is a documented return; abandoning the egress loop on it also | ||
| 19 | //! abandons the ACKs that would reopen the window. | ||
| 20 | //! 3. **Both flow-control windows get extended on consume.** Extending | ||
| 21 | //! only the stream's leaves the connection window to close instead, | ||
| 22 | //! and the DAEMON stalls a few hundred kilobytes in — a freeze with no | ||
| 23 | //! error on either side. | ||
| 24 | const std = @import("std"); | ||
| 25 | const quic = @import("quic"); | ||
| 26 | |||
| 27 | /// Shared with the listener rather than imported again: two @cImport blocks | ||
| 28 | /// over the same headers are two distinct type universes, and the egress | ||
| 29 | /// ring hands `ngtcp2_vec`s across this boundary. | ||
| 30 | const c = quic.c; | ||
| 31 | |||
| 32 | pub const Key = quic.Key; | ||
| 33 | pub const key_len = quic.key_len; | ||
| 34 | |||
| 35 | /// wolfSSL's PSK callback carries no user pointer, so the key has to be | ||
| 36 | /// reachable without one. A client process runs one connection at a time, | ||
| 37 | /// which makes this correct rather than merely convenient. | ||
| 38 | var g_key: ?Key = null; | ||
| 39 | |||
| 40 | fn pskClientCb( | ||
| 41 | _: ?*c.WOLFSSL, | ||
| 42 | _: [*c]const u8, | ||
| 43 | identity: [*c]u8, | ||
| 44 | id_max: c_uint, | ||
| 45 | key_out: [*c]u8, | ||
| 46 | key_max: c_uint, | ||
| 47 | ciphersuite: [*c][*c]const u8, | ||
| 48 | ) callconv(.c) c_uint { | ||
| 49 | const k = g_key orelse return 0; | ||
| 50 | if (id_max < 4 or key_max < key_len) return 0; | ||
| 51 | @memcpy(identity[0..4], "mux\x00"); | ||
| 52 | @memcpy(key_out[0..key_len], &k.bytes); | ||
| 53 | if (ciphersuite) |cs| cs.* = quic.psk_ciphersuite; | ||
| 54 | return key_len; | ||
| 55 | } | ||
| 56 | |||
| 57 | fn getConnCb(ref: [*c]c.ngtcp2_crypto_conn_ref) callconv(.c) ?*c.ngtcp2_conn { | ||
| 58 | const self: *Client = @ptrCast(@alignCast(ref.*.user_data)); | ||
| 59 | return self.conn; | ||
| 60 | } | ||
| 61 | |||
| 62 | fn randCb(dest: [*c]u8, destlen: usize, _: [*c]const c.ngtcp2_rand_ctx) callconv(.c) void { | ||
| 63 | std.crypto.random.bytes(dest[0..destlen]); | ||
| 64 | } | ||
| 65 | |||
| 66 | fn getNewCidCb( | ||
| 67 | _: ?*c.ngtcp2_conn, | ||
| 68 | cid: [*c]c.ngtcp2_cid, | ||
| 69 | token: [*c]c.ngtcp2_stateless_reset_token, | ||
| 70 | cidlen: usize, | ||
| 71 | _: ?*anyopaque, | ||
| 72 | ) callconv(.c) c_int { | ||
| 73 | std.crypto.random.bytes(cid.*.data[0..cidlen]); | ||
| 74 | cid.*.datalen = cidlen; | ||
| 75 | std.crypto.random.bytes(&token.*.data); | ||
| 76 | return 0; | ||
| 77 | } | ||
| 78 | |||
| 79 | fn handshakeCompletedCb(_: ?*c.ngtcp2_conn, ud: ?*anyopaque) callconv(.c) c_int { | ||
| 80 | const self: *Client = @ptrCast(@alignCast(ud.?)); | ||
| 81 | self.handshake_done = true; | ||
| 82 | return 0; | ||
| 83 | } | ||
| 84 | |||
| 85 | /// The peer granted stream credit: open the one stream this transport uses. | ||
| 86 | /// It cannot be opened before the handshake, which is why this is a callback | ||
| 87 | /// rather than a line in `connect`. | ||
| 88 | fn extendStreamsCb(conn: ?*c.ngtcp2_conn, _: u64, ud: ?*anyopaque) callconv(.c) c_int { | ||
| 89 | const self: *Client = @ptrCast(@alignCast(ud.?)); | ||
| 90 | if (self.stream_id == -1) { | ||
| 91 | var sid: i64 = -1; | ||
| 92 | if (c.ngtcp2_conn_open_bidi_stream(conn, &sid, null) == 0) self.stream_id = sid; | ||
| 93 | } | ||
| 94 | return 0; | ||
| 95 | } | ||
| 96 | |||
| 97 | fn recvStreamDataCb( | ||
| 98 | conn: ?*c.ngtcp2_conn, | ||
| 99 | _: u32, | ||
| 100 | stream_id: i64, | ||
| 101 | _: u64, | ||
| 102 | data: [*c]const u8, | ||
| 103 | datalen: usize, | ||
| 104 | ud: ?*anyopaque, | ||
| 105 | _: ?*anyopaque, | ||
| 106 | ) callconv(.c) c_int { | ||
| 107 | const self: *Client = @ptrCast(@alignCast(ud.?)); | ||
| 108 | |||
| 109 | // BOTH windows, and the connection-level one is the half that is easy to | ||
| 110 | // forget: extend only the stream and the daemon stops sending a few | ||
| 111 | // hundred kilobytes into a session — a scrollback fetch, a big | ||
| 112 | // snapshot — with no error anywhere. It presents as a freeze. | ||
| 113 | _ = c.ngtcp2_conn_extend_max_stream_offset(conn, stream_id, datalen); | ||
| 114 | _ = c.ngtcp2_conn_extend_max_offset(conn, datalen); | ||
| 115 | |||
| 116 | if (datalen > 0) { | ||
| 117 | self.in.appendSlice(self.alloc, data[0..datalen]) catch { | ||
| 118 | self.dead = true; | ||
| 119 | return 0; | ||
| 120 | }; | ||
| 121 | } | ||
| 122 | return 0; | ||
| 123 | } | ||
| 124 | |||
| 125 | fn ackedStreamDataCb( | ||
| 126 | _: ?*c.ngtcp2_conn, | ||
| 127 | _: i64, | ||
| 128 | _: u64, | ||
| 129 | datalen: u64, | ||
| 130 | ud: ?*anyopaque, | ||
| 131 | _: ?*anyopaque, | ||
| 132 | ) callconv(.c) c_int { | ||
| 133 | const self: *Client = @ptrCast(@alignCast(ud.?)); | ||
| 134 | self.out.ack(@intCast(datalen)); | ||
| 135 | return 0; | ||
| 136 | } | ||
| 137 | |||
| 138 | /// One connection to a muxd's QUIC listener. | ||
| 139 | pub const Client = struct { | ||
| 140 | alloc: std.mem.Allocator, | ||
| 141 | fd: std.posix.fd_t, | ||
| 142 | ssl_ctx: ?*c.WOLFSSL_CTX = null, | ||
| 143 | ssl: ?*c.WOLFSSL = null, | ||
| 144 | conn: ?*c.ngtcp2_conn = null, | ||
| 145 | conn_ref: c.ngtcp2_crypto_conn_ref = undefined, | ||
| 146 | remote: std.posix.sockaddr.storage = undefined, | ||
| 147 | remote_len: std.posix.socklen_t = 0, | ||
| 148 | local: std.posix.sockaddr.storage = undefined, | ||
| 149 | local_len: std.posix.socklen_t = 0, | ||
| 150 | stream_id: i64 = -1, | ||
| 151 | handshake_done: bool = false, | ||
| 152 | /// Set once this connection can never carry another byte: an idle | ||
| 153 | /// timeout, a protocol error, a peer that stopped answering. The caller | ||
| 154 | /// reads it as "the transport is gone" and reconnects. | ||
| 155 | dead: bool = false, | ||
| 156 | /// Outbound bytes, in the ring that does not move them until they are | ||
| 157 | /// acknowledged. See the module comment for why that matters. | ||
| 158 | out: quic.Egress, | ||
| 159 | /// Stream bytes the caller has not consumed yet. | ||
| 160 | in: std.ArrayList(u8) = .empty, | ||
| 161 | |||
| 162 | pub fn connect( | ||
| 163 | alloc: std.mem.Allocator, | ||
| 164 | addr: std.net.Address, | ||
| 165 | key: Key, | ||
| 166 | idle_ms: u32, | ||
| 167 | ) !*Client { | ||
| 168 | const fd = try std.posix.socket( | ||
| 169 | addr.any.family, | ||
| 170 | std.posix.SOCK.DGRAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, | ||
| 171 | 0, | ||
| 172 | ); | ||
| 173 | errdefer std.posix.close(fd); | ||
| 174 | |||
| 175 | const self = try alloc.create(Client); | ||
| 176 | errdefer alloc.destroy(self); | ||
| 177 | const ring = try alloc.alloc(u8, quic.egress_cap); | ||
| 178 | errdefer alloc.free(ring); | ||
| 179 | |||
| 180 | self.* = .{ | ||
| 181 | .alloc = alloc, | ||
| 182 | .fd = fd, | ||
| 183 | .out = .{ .buf = ring }, | ||
| 184 | .remote_len = addr.getOsSockLen(), | ||
| 185 | .local_len = @sizeOf(std.posix.sockaddr.storage), | ||
| 186 | }; | ||
| 187 | @memcpy( | ||
| 188 | std.mem.asBytes(&self.remote)[0..self.remote_len], | ||
| 189 | std.mem.asBytes(&addr.any)[0..self.remote_len], | ||
| 190 | ); | ||
| 191 | // Connected UDP: the kernel filters out anything from another | ||
| 192 | // address, which is one fewer thing this code has to check. | ||
| 193 | try std.posix.connect(fd, &addr.any, self.remote_len); | ||
| 194 | try std.posix.getsockname(fd, @ptrCast(&self.local), &self.local_len); | ||
| 195 | |||
| 196 | g_key = key; | ||
| 197 | try self.startTls(); | ||
| 198 | try self.startConn(idle_ms); | ||
| 199 | self.drain(); | ||
| 200 | return self; | ||
| 201 | } | ||
| 202 | |||
| 203 | fn startTls(self: *Client) !void { | ||
| 204 | if (c.wolfSSL_Init() != c.WOLFSSL_SUCCESS) return error.TlsInit; | ||
| 205 | const ctx = c.wolfSSL_CTX_new(c.wolfTLSv1_3_client_method()) orelse return error.TlsInit; | ||
| 206 | self.ssl_ctx = ctx; | ||
| 207 | if (c.ngtcp2_crypto_wolfssl_configure_client_context(ctx) != 0) return error.TlsInit; | ||
| 208 | c.wolfSSL_CTX_set_psk_client_tls13_callback(ctx, pskClientCb); | ||
| 209 | _ = c.wolfSSL_CTX_set_cipher_list(ctx, quic.psk_ciphersuite); | ||
| 210 | |||
| 211 | const ssl = c.wolfSSL_new(ctx) orelse return error.TlsInit; | ||
| 212 | self.ssl = ssl; | ||
| 213 | self.conn_ref = .{ .get_conn = getConnCb, .user_data = self }; | ||
| 214 | _ = c.wolfSSL_set_app_data(ssl, &self.conn_ref); | ||
| 215 | _ = c.wolfSSL_UseALPN( | ||
| 216 | ssl, | ||
| 217 | @constCast(quic.alpn[1..].ptr), | ||
| 218 | quic.alpn.len - 1, | ||
| 219 | c.WOLFSSL_ALPN_FAILED_ON_MISMATCH, | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | |||
| 223 | fn startConn(self: *Client, idle_ms: u32) !void { | ||
| 224 | var dcid: c.ngtcp2_cid = undefined; | ||
| 225 | dcid.datalen = 16; | ||
| 226 | std.crypto.random.bytes(dcid.data[0..16]); | ||
| 227 | var scid: c.ngtcp2_cid = undefined; | ||
| 228 | scid.datalen = 8; | ||
| 229 | std.crypto.random.bytes(scid.data[0..8]); | ||
| 230 | |||
| 231 | var cbs: c.ngtcp2_callbacks = std.mem.zeroes(c.ngtcp2_callbacks); | ||
| 232 | cbs.client_initial = c.ngtcp2_crypto_client_initial_cb; | ||
| 233 | cbs.recv_crypto_data = c.ngtcp2_crypto_recv_crypto_data_cb; | ||
| 234 | cbs.encrypt = c.ngtcp2_crypto_encrypt_cb; | ||
| 235 | cbs.decrypt = c.ngtcp2_crypto_decrypt_cb; | ||
| 236 | cbs.hp_mask = c.ngtcp2_crypto_hp_mask_cb; | ||
| 237 | // The listener answers every fresh Initial with a Retry, so a client | ||
| 238 | // that cannot process one never gets past its first flight. | ||
| 239 | cbs.recv_retry = c.ngtcp2_crypto_recv_retry_cb; | ||
| 240 | cbs.update_key = c.ngtcp2_crypto_update_key_cb; | ||
| 241 | cbs.delete_crypto_aead_ctx = c.ngtcp2_crypto_delete_crypto_aead_ctx_cb; | ||
| 242 | cbs.delete_crypto_cipher_ctx = c.ngtcp2_crypto_delete_crypto_cipher_ctx_cb; | ||
| 243 | cbs.get_path_challenge_data = c.ngtcp2_crypto_get_path_challenge_data_cb; | ||
| 244 | cbs.version_negotiation = c.ngtcp2_crypto_version_negotiation_cb; | ||
| 245 | cbs.rand = randCb; | ||
| 246 | cbs.get_new_connection_id2 = getNewCidCb; | ||
| 247 | cbs.handshake_completed = handshakeCompletedCb; | ||
| 248 | cbs.extend_max_local_streams_bidi = extendStreamsCb; | ||
| 249 | cbs.recv_stream_data = recvStreamDataCb; | ||
| 250 | cbs.acked_stream_data_offset = ackedStreamDataCb; | ||
| 251 | |||
| 252 | var settings: c.ngtcp2_settings = undefined; | ||
| 253 | c.ngtcp2_settings_default_versioned(c.NGTCP2_SETTINGS_VERSION, &settings); | ||
| 254 | settings.initial_ts = quic.timestampNs(); | ||
| 255 | |||
| 256 | var params: c.ngtcp2_transport_params = undefined; | ||
| 257 | c.ngtcp2_transport_params_default_versioned(c.NGTCP2_TRANSPORT_PARAMS_VERSION, ¶ms); | ||
| 258 | params.initial_max_streams_bidi = 4; | ||
| 259 | params.initial_max_stream_data_bidi_local = 256 * 1024; | ||
| 260 | params.initial_max_stream_data_bidi_remote = 256 * 1024; | ||
| 261 | params.initial_max_data = 1024 * 1024; | ||
| 262 | // Tunable for the same reason as the daemon's: the reconnect loop | ||
| 263 | // has to see a dead transport on a schedule a test can wait for. | ||
| 264 | params.max_idle_timeout = @as(u64, idle_ms) * 1_000_000; | ||
| 265 | |||
| 266 | var path: c.ngtcp2_path = .{ | ||
| 267 | .local = .{ .addr = @ptrCast(&self.local), .addrlen = self.local_len }, | ||
| 268 | .remote = .{ .addr = @ptrCast(&self.remote), .addrlen = self.remote_len }, | ||
| 269 | .user_data = null, | ||
| 270 | }; | ||
| 271 | var conn: ?*c.ngtcp2_conn = null; | ||
| 272 | if (c.ngtcp2_conn_client_new_versioned( | ||
| 273 | &conn, | ||
| 274 | &dcid, | ||
| 275 | &scid, | ||
| 276 | &path, | ||
| 277 | c.NGTCP2_PROTO_VER_V1, | ||
| 278 | c.NGTCP2_CALLBACKS_VERSION, | ||
| 279 | &cbs, | ||
| 280 | c.NGTCP2_SETTINGS_VERSION, | ||
| 281 | &settings, | ||
| 282 | c.NGTCP2_TRANSPORT_PARAMS_VERSION, | ||
| 283 | ¶ms, | ||
| 284 | null, | ||
| 285 | self, | ||
| 286 | ) != 0) return error.ConnInit; | ||
| 287 | self.conn = conn; | ||
| 288 | c.ngtcp2_conn_set_tls_native_handle(conn, self.ssl); | ||
| 289 | // Silence is not death: a terminal nobody is typing into must not be | ||
| 290 | // dropped at the idle timeout. A third of it, matching the daemon. | ||
| 291 | c.ngtcp2_conn_set_keep_alive_timeout(conn, keepAliveNs(idle_ms)); | ||
| 292 | } | ||
| 293 | |||
| 294 | pub fn deinit(self: *Client) void { | ||
| 295 | self.in.deinit(self.alloc); | ||
| 296 | self.out.deinit(self.alloc); | ||
| 297 | if (self.conn) |cn| c.ngtcp2_conn_del(cn); | ||
| 298 | if (self.ssl) |s| c.wolfSSL_free(s); | ||
| 299 | if (self.ssl_ctx) |x| c.wolfSSL_CTX_free(x); | ||
| 300 | std.posix.close(self.fd); | ||
| 301 | self.alloc.destroy(self); | ||
| 302 | } | ||
| 303 | |||
| 304 | /// The descriptor to poll. Readable does NOT mean "a frame is waiting": | ||
| 305 | /// it means a datagram arrived, which may be an ack, a handshake flight, | ||
| 306 | /// or part of a frame. See `Incoming` in client.zig. | ||
| 307 | pub fn pollFd(self: *const Client) std.posix.fd_t { | ||
| 308 | return self.fd; | ||
| 309 | } | ||
| 310 | |||
| 311 | /// Ready to carry the caller's bytes: handshake finished AND the stream | ||
| 312 | /// open. Both, because a handshake without a stream has nowhere to put | ||
| 313 | /// them and would silently hold everything in the ring. | ||
| 314 | pub fn isReady(self: *const Client) bool { | ||
| 315 | return self.handshake_done and self.stream_id != -1 and !self.dead; | ||
| 316 | } | ||
| 317 | |||
| 318 | /// Milliseconds until ngtcp2 next wants servicing, capped. Feeds the | ||
| 319 | /// client's existing poll timeout — no timer fd, same as the daemon. | ||
| 320 | pub fn timeoutMs(self: *Client, cap_ms: i32) i32 { | ||
| 321 | const conn = self.conn orelse return cap_ms; | ||
| 322 | const expiry = c.ngtcp2_conn_get_expiry(conn); | ||
| 323 | if (expiry == std.math.maxInt(u64)) return cap_ms; | ||
| 324 | const now = quic.timestampNs(); | ||
| 325 | if (expiry <= now) return 0; | ||
| 326 | return @intCast(@min(@as(u64, @intCast(cap_ms)), (expiry - now) / 1_000_000)); | ||
| 327 | } | ||
| 328 | |||
| 329 | /// One service pass: take what the socket has, run whatever timers are | ||
| 330 | /// due, push whatever egress is ready. Safe to call at any time. | ||
| 331 | pub fn pump(self: *Client) void { | ||
| 332 | if (self.dead) return; | ||
| 333 | self.readable(); | ||
| 334 | self.tick(); | ||
| 335 | self.drain(); | ||
| 336 | } | ||
| 337 | |||
| 338 | fn readable(self: *Client) void { | ||
| 339 | var buf: [65536]u8 = undefined; | ||
| 340 | while (true) { | ||
| 341 | const n = std.posix.recv(self.fd, &buf, 0) catch |err| switch (err) { | ||
| 342 | error.WouldBlock => return, | ||
| 343 | // ECONNREFUSED on a connected UDP socket means the port is | ||
| 344 | // not there: an ICMP unreachable came back. That is a dead | ||
| 345 | // transport, not a blip to keep polling. | ||
| 346 | error.ConnectionRefused => { | ||
| 347 | self.dead = true; | ||
| 348 | return; | ||
| 349 | }, | ||
| 350 | else => return, | ||
| 351 | }; | ||
| 352 | if (n == 0) continue; | ||
| 353 | const conn = self.conn orelse return; | ||
| 354 | var path: c.ngtcp2_path = .{ | ||
| 355 | .local = .{ .addr = @ptrCast(&self.local), .addrlen = self.local_len }, | ||
| 356 | .remote = .{ .addr = @ptrCast(&self.remote), .addrlen = self.remote_len }, | ||
| 357 | .user_data = null, | ||
| 358 | }; | ||
| 359 | var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 }; | ||
| 360 | if (c.ngtcp2_conn_read_pkt(conn, &path, &pi, &buf, n, quic.timestampNs()) != 0) { | ||
| 361 | self.dead = true; | ||
| 362 | return; | ||
| 363 | } | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | fn tick(self: *Client) void { | ||
| 368 | const conn = self.conn orelse return; | ||
| 369 | const now = quic.timestampNs(); | ||
| 370 | if (c.ngtcp2_conn_get_expiry(conn) > now) return; | ||
| 371 | // An idle timeout arrives here, which is how the reconnect loop | ||
| 372 | // learns the daemon stopped answering. | ||
| 373 | if (c.ngtcp2_conn_handle_expiry(conn, now) != 0) self.dead = true; | ||
| 374 | } | ||
| 375 | |||
| 376 | fn drain(self: *Client) void { | ||
| 377 | const conn = self.conn orelse return; | ||
| 378 | var buf: [quic.max_udp]u8 = undefined; | ||
| 379 | var stream_blocked = false; | ||
| 380 | while (true) { | ||
| 381 | var ps: c.ngtcp2_path_storage = undefined; | ||
| 382 | c.ngtcp2_path_storage_zero(&ps); | ||
| 383 | var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 }; | ||
| 384 | var wrote: c.ngtcp2_ssize = 0; | ||
| 385 | |||
| 386 | var vecs: [2]c.ngtcp2_vec = undefined; | ||
| 387 | var vcnt: usize = 0; | ||
| 388 | var sid: i64 = -1; | ||
| 389 | if (!stream_blocked and self.stream_id != -1) { | ||
| 390 | vcnt = self.out.vecs(&vecs); | ||
| 391 | if (vcnt > 0) sid = self.stream_id; | ||
| 392 | } | ||
| 393 | |||
| 394 | const n = c.ngtcp2_conn_writev_stream_versioned( | ||
| 395 | conn, | ||
| 396 | &ps.path, | ||
| 397 | c.NGTCP2_PKT_INFO_VERSION, | ||
| 398 | &pi, | ||
| 399 | &buf, | ||
| 400 | buf.len, | ||
| 401 | &wrote, | ||
| 402 | 0, | ||
| 403 | sid, | ||
| 404 | if (vcnt > 0) &vecs else null, | ||
| 405 | vcnt, | ||
| 406 | quic.timestampNs(), | ||
| 407 | ); | ||
| 408 | if (n == c.NGTCP2_ERR_STREAM_DATA_BLOCKED or n == c.NGTCP2_ERR_STREAM_SHUT_WR) { | ||
| 409 | // Documented, not fatal. Retry the same iteration carrying | ||
| 410 | // no stream data so ACKs and keepalives still leave: they | ||
| 411 | // are how the peer's window reopens, and a client that | ||
| 412 | // stopped here would stall exactly as the daemon did. | ||
| 413 | stream_blocked = true; | ||
| 414 | continue; | ||
| 415 | } | ||
| 416 | if (n < 0) { | ||
| 417 | self.dead = true; | ||
| 418 | return; | ||
| 419 | } | ||
| 420 | if (wrote > 0) self.out.took(@intCast(wrote)); | ||
| 421 | if (n == 0) return; | ||
| 422 | _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch return; | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | /// Take what fits and report how much. A short return is the caller's | ||
| 427 | /// signal to keep the rest and offer it again — the same contract the | ||
| 428 | /// daemon's sink obeys, and for the same reason: the ring is bounded, so | ||
| 429 | /// somebody has to hold the backlog and it should be somebody who can | ||
| 430 | /// see how big it is. | ||
| 431 | pub fn send(self: *Client, bytes: []const u8) usize { | ||
| 432 | if (self.dead or self.stream_id == -1) return 0; | ||
| 433 | const n = self.out.push(bytes); | ||
| 434 | if (n > 0) self.drain(); | ||
| 435 | return n; | ||
| 436 | } | ||
| 437 | |||
| 438 | /// Stream bytes received and not yet consumed. | ||
| 439 | pub fn inbound(self: *const Client) []const u8 { | ||
| 440 | return self.in.items; | ||
| 441 | } | ||
| 442 | |||
| 443 | /// Drop `n` bytes off the front of the inbound buffer. | ||
| 444 | pub fn consume(self: *Client, n: usize) void { | ||
| 445 | const take = @min(n, self.in.items.len); | ||
| 446 | const rest = self.in.items.len - take; | ||
| 447 | std.mem.copyForwards(u8, self.in.items[0..rest], self.in.items[take..]); | ||
| 448 | self.in.shrinkRetainingCapacity(rest); | ||
| 449 | } | ||
| 450 | }; | ||
| 451 | |||
| 452 | /// A third of the idle timeout, so two keepalives can go unanswered before | ||
| 453 | /// the connection is called dead. Never zero: ngtcp2 reads zero as | ||
| 454 | /// "disabled", which would restore the behaviour this exists to prevent. | ||
| 455 | fn keepAliveNs(idle_ms: u32) u64 { | ||
| 456 | return @max(1, @as(u64, idle_ms) / 3) * 1_000_000; | ||
| 457 | } | ||
| 458 | |||
| 459 | test "keepAlive: a third of the idle timeout, and never disabled" { | ||
| 460 | try std.testing.expectEqual(@as(u64, 5_000_000_000), keepAliveNs(15_000)); | ||
| 461 | try std.testing.expectEqual(@as(u64, 500_000_000), keepAliveNs(1500)); | ||
| 462 | // Zero would mean "no keepalive" to ngtcp2 — the opposite of what a | ||
| 463 | // small idle timeout is asking for. | ||
| 464 | try std.testing.expectEqual(@as(u64, 1_000_000), keepAliveNs(1)); | ||
| 465 | try std.testing.expectEqual(@as(u64, 1_000_000), keepAliveNs(2)); | ||
| 466 | } | ||
| 467 | |||
| 468 | test "Client.consume: takes from the front and keeps the rest" { | ||
| 469 | const alloc = std.testing.allocator; | ||
| 470 | var cl: Client = .{ .alloc = alloc, .fd = -1, .out = .{ .buf = &.{} } }; | ||
| 471 | defer cl.in.deinit(alloc); | ||
| 472 | |||
| 473 | try cl.in.appendSlice(alloc, "abcdefgh"); | ||
| 474 | cl.consume(3); | ||
| 475 | try std.testing.expectEqualStrings("defgh", cl.inbound()); | ||
| 476 | cl.consume(0); | ||
| 477 | try std.testing.expectEqualStrings("defgh", cl.inbound()); | ||
| 478 | // Consuming more than is there is not an error: a frame walk that asked | ||
| 479 | // for a payload it had already been handed would otherwise corrupt the | ||
| 480 | // buffer rather than say so. | ||
| 481 | cl.consume(99); | ||
| 482 | try std.testing.expectEqualStrings("", cl.inbound()); | ||
| 483 | } | ||
src/quic_server.zig
| Old | New | ||
|---|---|---|---|
| @@ -13,7 +13,11 @@ | |||
| 13 | //! for the integration assessment this implements. | 13 | //! for the integration assessment this implements. |
| 14 | const std = @import("std"); | 14 | const std = @import("std"); |
| 15 | 15 | ||
| 16 | const c = @cImport({ | 16 | /// The C view of the QUIC stack. Exported because the client transport |
| 17 | /// shares it: two @cImport blocks over the same headers produce two | ||
| 18 | /// *distinct* Zig types, so a client with its own would find that | ||
| 19 | /// `ngtcp2_vec` is not `ngtcp2_vec`. One import, one type universe. | ||
| 20 | pub const c = @cImport({ | ||
| 17 | @cInclude("ngtcp2/ngtcp2.h"); | 21 | @cInclude("ngtcp2/ngtcp2.h"); |
| 18 | @cInclude("ngtcp2/ngtcp2_crypto.h"); | 22 | @cInclude("ngtcp2/ngtcp2_crypto.h"); |
| 19 | @cInclude("ngtcp2/ngtcp2_crypto_wolfssl.h"); | 23 | @cInclude("ngtcp2/ngtcp2_crypto_wolfssl.h"); |
| @@ -233,7 +237,7 @@ const max_conns = 16; | |||
| 233 | /// primary CID is matched separately, a cache that ever did fill would | 237 | /// primary CID is matched separately, a cache that ever did fill would |
| 234 | /// degrade to the old primary-only behaviour rather than misroute. | 238 | /// degrade to the old primary-only behaviour rather than misroute. |
| 235 | const max_cids = 16; | 239 | const max_cids = 16; |
| 236 | const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting | 240 | pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting |
| 237 | 241 | ||
| 238 | /// wolfSSL's PSK callbacks carry no user pointer, so the key has to be | 242 | /// wolfSSL's PSK callbacks carry no user pointer, so the key has to be |
| 239 | /// reachable without one. A daemon runs a single listener, which makes a | 243 | /// reachable without one. A daemon runs a single listener, which makes a |
| @@ -262,15 +266,15 @@ fn pskServerCb( | |||
| 262 | return key_len; | 266 | return key_len; |
| 263 | } | 267 | } |
| 264 | 268 | ||
| 265 | const psk_identity: [*:0]const u8 = "mux"; | 269 | pub const psk_identity: [*:0]const u8 = "mux"; |
| 266 | const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256"; | 270 | pub const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256"; |
| 267 | const alpn = "\x03mux"; | 271 | pub const alpn = "\x03mux"; |
| 268 | 272 | ||
| 269 | /// How many outbound bytes one connection may hold. Sized to the stream | 273 | /// 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 | 274 | /// 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 | 275 | /// 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. | 276 | /// right place for the backlog to sit and be judged. |
| 273 | const egress_cap = 256 * 1024; | 277 | pub const egress_cap = 256 * 1024; |
| 274 | 278 | ||
| 275 | /// Outbound stream bytes, in a ring that never moves a byte once written. | 279 | /// Outbound stream bytes, in a ring that never moves a byte once written. |
| 276 | /// | 280 | /// |
| @@ -289,7 +293,7 @@ const egress_cap = 256 * 1024; | |||
| 289 | /// it.** `head` advances only from `acked_stream_data_offset`; a write can | 293 | /// 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 | 294 | /// 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. | 295 | /// half of the point — a full ring is the backpressure signal. |
| 292 | const Egress = struct { | 296 | pub const Egress = struct { |
| 293 | buf: []u8, | 297 | buf: []u8, |
| 294 | /// The oldest byte the peer has not acknowledged. | 298 | /// The oldest byte the peer has not acknowledged. |
| 295 | head: usize = 0, | 299 | head: usize = 0, |
| @@ -299,19 +303,19 @@ const Egress = struct { | |||
| 299 | /// The tail of `held` that ngtcp2 has not taken yet. | 303 | /// The tail of `held` that ngtcp2 has not taken yet. |
| 300 | unsent: usize = 0, | 304 | unsent: usize = 0, |
| 301 | 305 | ||
| 302 | fn deinit(self: *Egress, alloc: std.mem.Allocator) void { | 306 | pub fn deinit(self: *Egress, alloc: std.mem.Allocator) void { |
| 303 | alloc.free(self.buf); | 307 | alloc.free(self.buf); |
| 304 | self.* = .{ .buf = &.{} }; | 308 | self.* = .{ .buf = &.{} }; |
| 305 | } | 309 | } |
| 306 | 310 | ||
| 307 | fn freeSpace(self: *const Egress) usize { | 311 | pub fn freeSpace(self: *const Egress) usize { |
| 308 | return self.buf.len - self.held; | 312 | return self.buf.len - self.held; |
| 309 | } | 313 | } |
| 310 | 314 | ||
| 311 | /// Take what fits and report how much that was. A short return is not an | 315 | /// 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 | 316 | /// error, it is the whole mechanism: the caller keeps the remainder and |
| 313 | /// is thereby the one holding — and bounding — the backlog. | 317 | /// is thereby the one holding — and bounding — the backlog. |
| 314 | fn push(self: *Egress, bytes: []const u8) usize { | 318 | pub fn push(self: *Egress, bytes: []const u8) usize { |
| 315 | const n = @min(bytes.len, self.freeSpace()); | 319 | const n = @min(bytes.len, self.freeSpace()); |
| 316 | if (n == 0) return 0; | 320 | if (n == 0) return 0; |
| 317 | const start = (self.head + self.held) % self.buf.len; | 321 | const start = (self.head + self.held) % self.buf.len; |
| @@ -326,7 +330,7 @@ const Egress = struct { | |||
| 326 | /// The unsent region as up to two vectors — two when it wraps, which is | 330 | /// 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 | 331 | /// the price of never moving a byte, and ngtcp2 takes a vector array |
| 328 | /// precisely so that price is payable. | 332 | /// precisely so that price is payable. |
| 329 | fn vecs(self: *const Egress, out: *[2]c.ngtcp2_vec) usize { | 333 | pub fn vecs(self: *const Egress, out: *[2]c.ngtcp2_vec) usize { |
| 330 | if (self.unsent == 0) return 0; | 334 | if (self.unsent == 0) return 0; |
| 331 | const start = (self.head + (self.held - self.unsent)) % self.buf.len; | 335 | const start = (self.head + (self.held - self.unsent)) % self.buf.len; |
| 332 | const first = @min(self.unsent, self.buf.len - start); | 336 | const first = @min(self.unsent, self.buf.len - start); |
| @@ -338,7 +342,7 @@ const Egress = struct { | |||
| 338 | 342 | ||
| 339 | /// ngtcp2 took `n` bytes off the unsent region. They stay exactly where | 343 | /// ngtcp2 took `n` bytes off the unsent region. They stay exactly where |
| 340 | /// they are — it now has pointers to them. | 344 | /// they are — it now has pointers to them. |
| 341 | fn took(self: *Egress, n: usize) void { | 345 | pub fn took(self: *Egress, n: usize) void { |
| 342 | self.unsent -= @min(n, self.unsent); | 346 | self.unsent -= @min(n, self.unsent); |
| 343 | } | 347 | } |
| 344 | 348 | ||
| @@ -346,7 +350,7 @@ const Egress = struct { | |||
| 346 | /// as arriving "sequentially in increasing order of offset without any | 350 | /// as arriving "sequentially in increasing order of offset without any |
| 347 | /// overlap", so a running count IS the acknowledged prefix, and this is | 351 | /// overlap", so a running count IS the acknowledged prefix, and this is |
| 348 | /// the only thing that ever frees space. | 352 | /// the only thing that ever frees space. |
| 349 | fn ack(self: *Egress, n: usize) void { | 353 | pub fn ack(self: *Egress, n: usize) void { |
| 350 | const taken = @min(n, self.held - self.unsent); | 354 | const taken = @min(n, self.held - self.unsent); |
| 351 | self.head = (self.head + taken) % self.buf.len; | 355 | self.head = (self.head + taken) % self.buf.len; |
| 352 | self.held -= taken; | 356 | self.held -= taken; |
| @@ -1100,7 +1104,7 @@ fn keepAliveNs(idle_ms: u64) u64 { | |||
| 1100 | return @max(1, idle_ms / 3) * 1_000_000; | 1104 | return @max(1, idle_ms / 3) * 1_000_000; |
| 1101 | } | 1105 | } |
| 1102 | 1106 | ||
| 1103 | fn timestampNs() u64 { | 1107 | pub fn timestampNs() u64 { |
| 1104 | const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0; | 1108 | const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch return 0; |
| 1105 | return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); | 1109 | return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); |
| 1106 | } | 1110 | } |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -991,14 +991,23 @@ pub const Server = struct { | |||
| 991 | fn handleFrame(self: *Server, i: usize, frame: proto.Frame) void { | 991 | fn handleFrame(self: *Server, i: usize, frame: proto.Frame) void { |
| 992 | switch (frame.type) { | 992 | switch (frame.type) { |
| 993 | .attach => { | 993 | .attach => { |
| 994 | // Re-attach over a connection that is already attached. Its | 994 | // This used to snapshot unconditionally, on the reasoning |
| 995 | // have_seq would be serviceable, but a client attaching | 995 | // that a client attaching twice over one connection is rare |
| 996 | // twice over one connection is rare enough that a second | 996 | // enough not to deserve a second resync path. That was true |
| 997 | // resync path isn't worth carrying — snapshot it (which, | 997 | // while every client began life as an observer and did its |
| 998 | // like any resize, everyone else hears about too). | 998 | // FIRST attach from there — and it stopped being true the |
| 999 | // moment QUIC clients existed, because a QUIC connection is | ||
| 1000 | // promoted to a client slot when its handshake completes, so | ||
| 1001 | // its first attach arrives HERE. Every QUIC attach was | ||
| 1002 | // therefore snapshot-served, and a reconnecting QUIC client | ||
| 1003 | // could never resume from a delta no matter what it held. | ||
| 1004 | // Found by the reconnect scenario in e2e, which asserts the | ||
| 1005 | // counter rather than the rendering. | ||
| 999 | const req = proto.decodeAttach(frame.payload) catch return; | 1006 | const req = proto.decodeAttach(frame.payload) catch return; |
| 1000 | if (self.applySize(req.cols, req.rows)) self.recordSize(i); | 1007 | const size_changed = (req.cols != self.colsNow() or req.rows != self.rowsNow()); |
| 1001 | self.resyncSnapshot(); | 1008 | const applied = self.applySize(req.cols, req.rows); |
| 1009 | if (applied) self.recordSize(i); | ||
| 1010 | self.sendResync(i, req.have_seq, req.have_epoch, size_changed and applied); | ||
| 1002 | }, | 1011 | }, |
| 1003 | .resize => { | 1012 | .resize => { |
| 1004 | // Latest wins: whoever resized last sets the grid, and the | 1013 | // Latest wins: whoever resized last sets the grid, and the |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -54,7 +54,7 @@ cleanup() { | |||
| 54 | rm -f "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ | 54 | rm -f "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ |
| 55 | "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \ | 55 | "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" \ |
| 56 | "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \ | 56 | "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \ |
| 57 | "$OUT.q" | 57 | "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$QKEY.wrong" |
| 58 | } | 58 | } |
| 59 | trap cleanup EXIT INT TERM | 59 | trap cleanup EXIT INT TERM |
| 60 | 60 | ||
| @@ -393,8 +393,130 @@ grep -q "quic-flags-ok" "$OUT.q" || { | |||
| 393 | echo "e2e FAIL: --quic daemon's grid missing output"; exit 1; | 393 | echo "e2e FAIL: --quic daemon's grid missing output"; exit 1; |
| 394 | } | 394 | } |
| 395 | kill -0 "$D4PID" || { echo "e2e FAIL: --quic daemon died"; exit 1; } | 395 | kill -0 "$D4PID" || { echo "e2e FAIL: --quic daemon died"; exit 1; } |
| 396 | |||
| 397 | # --- M8 Task 3: the client speaks quic://. Same daemon, new transport. | ||
| 398 | |||
| 399 | # 1. Attach, run something, detach. The whole point of the milestone in one | ||
| 400 | # scenario: the wire protocol did not change, so this must behave exactly | ||
| 401 | # as the socket client does. | ||
| 402 | set +e | ||
| 403 | { printf 'printf "quic-%%s\\n" attach-ok\n'; sleep 2; printf '\034'; } | \ | ||
| 404 | timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" > "$OUT.qc" 2>&1 | ||
| 405 | RC=$? | ||
| 406 | set -e | ||
| 407 | [ "$RC" -eq 0 ] || { | ||
| 408 | echo "e2e FAIL: quic:// client exited $RC (want 0)"; cat "$OUT.qc"; exit 1; | ||
| 409 | } | ||
| 410 | grep -q "quic-attach-ok" "$OUT.qc" || { | ||
| 411 | echo "e2e FAIL: quic:// client render missing output"; cat "$OUT.qc"; exit 1; | ||
| 412 | } | ||
| 413 | "$MUXD" dump --sock "$SOCK4" | grep -q "quic-attach-ok" || { | ||
| 414 | echo "e2e FAIL: daemon grid missing the quic:// client's output"; exit 1; | ||
| 415 | } | ||
| 416 | |||
| 417 | # 2. The key is checked, and a wrong one is refused loudly rather than | ||
| 418 | # retried forever. Nothing was ever established, so the reconnect loop | ||
| 419 | # must not engage — that is the never-established gate, over QUIC. | ||
| 420 | head -c 32 /dev/urandom > "$QKEY.wrong" | ||
| 421 | chmod 600 "$QKEY.wrong" | ||
| 422 | set +e | ||
| 423 | : | timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \ | ||
| 424 | --quic-idle-ms 1500 > "$OUT.qc" 2>&1 | ||
| 425 | RC=$? | ||
| 426 | set -e | ||
| 427 | [ "$RC" -eq 1 ] || { | ||
| 428 | echo "e2e FAIL: wrong-key quic client exited $RC (want 1; 124 means it retried)" | ||
| 429 | cat "$OUT.qc"; exit 1; | ||
| 430 | } | ||
| 431 | grep -q "did not answer" "$OUT.qc" || { | ||
| 432 | echo "e2e FAIL: wrong-key quic client said nothing useful:"; cat "$OUT.qc"; exit 1; | ||
| 433 | } | ||
| 434 | |||
| 435 | # 3. The abort key works DURING a handshake, not just after it. waitReady | ||
| 436 | # runs inside Transport.open, after drainStdinForQuit has returned, so | ||
| 437 | # watching only the socket left nothing looking for Ctrl-\ for as long as | ||
| 438 | # the handshake bound allows — and during a reconnect the terminal is in | ||
| 439 | # raw mode, where Ctrl-\ is the only way out. A deliberately unreachable | ||
| 440 | # port makes the handshake run its full length if nothing interrupts it. | ||
| 441 | QDEAD=$(( QPORT + 1 )) | ||
| 442 | # A fifo rather than a pipeline, so what is timed is the CLIENT's exit and | ||
| 443 | # not how long the writer happened to hang around afterwards. | ||
| 444 | QFIFO="${TMPDIR:-/tmp}/mux-e2e-abort-fifo-$$" | ||
| 445 | mkfifo "$QFIFO" | ||
| 446 | ( sleep 0.3; printf '\034'; sleep 20 ) > "$QFIFO" & | ||
| 447 | QWPID=$! | ||
| 448 | QT0=$(date +%s%N) | ||
| 449 | set +e | ||
| 450 | timeout 30 "$MUX" "quic://127.0.0.1:$QDEAD" --key "$QKEY" \ | ||
| 451 | --quic-idle-ms 15000 < "$QFIFO" > "$OUT.qa" 2>&1 | ||
| 452 | RC=$? | ||
| 453 | set -e | ||
| 454 | QT1=$(date +%s%N) | ||
| 455 | kill "$QWPID" 2>/dev/null || true | ||
| 456 | rm -f "$QFIFO" | ||
| 457 | QMS=$(( (QT1 - QT0) / 1000000 )) | ||
| 458 | [ "$RC" -eq 0 ] || { | ||
| 459 | echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)" | ||
| 460 | cat "$OUT.qa"; exit 1; | ||
| 461 | } | ||
| 462 | # Generous against the 15000ms bound but far below it: the point is that the | ||
| 463 | # abort is answered on the user's schedule, not the handshake's. Measured at | ||
| 464 | # ~200ms; anything under 5s proves stdin was being watched. | ||
| 465 | [ "$QMS" -lt 5000 ] || { | ||
| 466 | echo "e2e FAIL: abort during handshake took ${QMS}ms (want well under the 15000ms bound)" | ||
| 467 | exit 1; | ||
| 468 | } | ||
| 469 | |||
| 470 | # 4. Reconnect over QUIC, with the resume kind asserted rather than assumed. | ||
| 471 | # The tear is a SIGSTOP held past the client's idle timeout: the daemon | ||
| 472 | # stops answering, the client declares the transport dead and reconnects. | ||
| 473 | # Deterministic, needs no privileges, and unlike kill -9 it leaves the | ||
| 474 | # session alive so the resume can be a DELTA rather than a fresh snapshot. | ||
| 475 | SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p') | ||
| 476 | [ -n "$SNAPS_BEFORE" ] || { echo "e2e FAIL: could not read snapshots before the tear"; exit 1; } | ||
| 477 | |||
| 478 | set +e | ||
| 479 | { printf 'printf "quic-%%s\\n" pre-tear\n'; sleep 12; printf '\034'; } | \ | ||
| 480 | timeout 40 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY" \ | ||
| 481 | --quic-idle-ms 1500 > "$OUT.qr" 2>&1 & | ||
| 482 | QRPID=$! | ||
| 483 | set -e | ||
| 484 | wait_for "$OUT.qr" "quic-pre-tear" 20 || { | ||
| 485 | echo "e2e FAIL: quic reconnect client never got its pre-tear marker" | ||
| 486 | cat "$OUT.qr"; exit 1; | ||
| 487 | } | ||
| 488 | |||
| 489 | # Held well past the 1500ms idle timeout, so the client cannot mistake it | ||
| 490 | # for a slow moment. | ||
| 491 | kill -STOP "$D4PID" | ||
| 492 | sleep 4 | ||
| 493 | kill -CONT "$D4PID" | ||
| 494 | |||
| 495 | set +e | ||
| 496 | wait "$QRPID" | ||
| 497 | RC=$? | ||
| 498 | set -e | ||
| 499 | [ "$RC" -eq 0 ] || { | ||
| 500 | echo "e2e FAIL: quic client exited $RC across a tear (want 0)"; cat "$OUT.qr"; exit 1; | ||
| 501 | } | ||
| 502 | grep -q "quic-pre-tear" "$OUT.qr" || { | ||
| 503 | echo "e2e FAIL: quic client lost its session across the tear"; cat "$OUT.qr"; exit 1; | ||
| 504 | } | ||
| 505 | |||
| 506 | # The counter is the only witness to HOW the resume was served: a snapshot | ||
| 507 | # renders identically to a delta, so markers cannot tell them apart. One | ||
| 508 | # more snapshot is the fresh attach at the start of this scenario; a second | ||
| 509 | # would mean the reconnect was resynced from scratch instead of resumed. | ||
| 510 | SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK4" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p') | ||
| 511 | [ -n "$SNAPS_AFTER" ] || { echo "e2e FAIL: could not read snapshots after the tear"; exit 1; } | ||
| 512 | [ "$((SNAPS_AFTER - SNAPS_BEFORE))" -eq 1 ] || { | ||
| 513 | echo "e2e FAIL: quic reconnect served $((SNAPS_AFTER - SNAPS_BEFORE)) snapshots (want 1:" | ||
| 514 | echo " the attach only; the resume itself must be delta-served)" | ||
| 515 | exit 1; | ||
| 516 | } | ||
| 517 | |||
| 396 | kill "$D4PID" 2>/dev/null || true | 518 | kill "$D4PID" 2>/dev/null || true |
| 397 | D4PID="" | 519 | D4PID="" |
| 398 | rm -f "$OUT.q" "$QKEY" "$QKEY.bad" | 520 | rm -f "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$QKEY" "$QKEY.bad" "$QKEY.wrong" |
| 399 | 521 | ||
| 400 | echo "e2e OK" | 522 | echo "e2e OK" |