633b38dd
test: wsclient — scripted WebSocket replica client for hub scenarios
a73x 2026-08-13 10:44
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -478,6 +478,20 @@ pub fn build(b: *std.Build) void { | |||
| 478 | // The page's three assets arrive as anonymous imports so @embedFile | 478 | // The page's three assets arrive as anonymous imports so @embedFile |
| 479 | // can name them; the wasm one is the artifact itself, which also | 479 | // can name them; the wasm one is the artifact itself, which also |
| 480 | // sequences the wasm build before the hub's. | 480 | // sequences the wasm build before the hub's. |
| 481 | const wsclient_mod = b.createModule(.{ | ||
| 482 | .root_source_file = b.path("test/wsclient.zig"), | ||
| 483 | .target = target, | ||
| 484 | .optimize = optimize, | ||
| 485 | .link_libc = true, | ||
| 486 | }); | ||
| 487 | wsclient_mod.addImport("engine", engine_mod); | ||
| 488 | wsclient_mod.addImport("replica", replica_mod); | ||
| 489 | wsclient_mod.addImport("protocol", protocol_mod); | ||
| 490 | const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod }); | ||
| 491 | wsclient_exe.use_llvm = true; | ||
| 492 | wsclient_exe.use_lld = true; | ||
| 493 | b.installArtifact(wsclient_exe); | ||
| 494 | |||
| 481 | const webhub_main_mod = b.createModule(.{ | 495 | const webhub_main_mod = b.createModule(.{ |
| 482 | .root_source_file = b.path("src/webhub_main.zig"), | 496 | .root_source_file = b.path("src/webhub_main.zig"), |
| 483 | .target = target, | 497 | .target = target, |
| @@ -509,7 +523,7 @@ pub fn build(b: *std.Build) void { | |||
| 509 | // absence here was a live hazard recorded in decisions.md — muxd's | 523 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 510 | // entrypoint could grow tests that silently never ran, exactly as | 524 | // entrypoint could grow tests that silently never ran, exactly as |
| 511 | // mux_main.zig's five did before it was added. | 525 | // mux_main.zig's five did before it was added. |
| 512 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod}) |mod| { | 526 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| { |
| 513 | const t = b.addTest(.{ .root_module = mod }); | 527 | const t = b.addTest(.{ .root_module = mod }); |
| 514 | t.use_llvm = true; | 528 | t.use_llvm = true; |
| 515 | t.use_lld = true; | 529 | t.use_lld = true; |
test/wsclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,529 @@ | |||
| 1 | //! e2e fixture: a scripted WebSocket client standing in for the browser | ||
| 2 | //! (M-web Task 9). Speaks the hub's wire (RFC 6455 client side, masked; | ||
| 3 | //! one envelope byte per message) and maintains a REAL replica from the | ||
| 4 | //! frames it receives — the same replica.zig the CLI and the wasm core | ||
| 5 | //! run — so its `dumpexit` grid diffs honestly against `muxd dump`. | ||
| 6 | //! Driven by a line-oriented script on stdin, ptyclient's conventions: | ||
| 7 | //! decodeEscapes, expect deadlines in ms, exit codes 2/3/4. | ||
| 8 | //! | ||
| 9 | //! Verbs: | ||
| 10 | //! attach C R send an attach quoting the replica's resume args | ||
| 11 | //! attachfresh C R same, quoting (0,0) | ||
| 12 | //! send BYTES input frame (escapes decoded) | ||
| 13 | //! resize C R resize frame | ||
| 14 | //! expectgrid NEEDLE MS poll the replica's plain dump for NEEDLE | ||
| 15 | //! expectstate STATE MS wait for a control message naming STATE | ||
| 16 | //! settle QUIET MS drain frames until QUIET ms of silence | ||
| 17 | //! dumpexit write the replica grid (muxd dump format) to | ||
| 18 | //! --out and exit 0 | ||
| 19 | //! | ||
| 20 | //! Usage: wsclient --port N --tile IDX --out FILE --err FILE | ||
| 21 | //! [--origin STR] < script | ||
| 22 | const std = @import("std"); | ||
| 23 | const Engine = @import("engine").Engine; | ||
| 24 | const Replica = @import("replica").Replica; | ||
| 25 | const proto = @import("protocol"); | ||
| 26 | |||
| 27 | const EXIT_USAGE: u8 = 2; | ||
| 28 | const EXIT_TIMEOUT: u8 = 3; | ||
| 29 | const EXIT_DIED: u8 = 4; | ||
| 30 | |||
| 31 | var err_file: ?std.fs.File = null; | ||
| 32 | |||
| 33 | fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn { | ||
| 34 | var buf: [512]u8 = undefined; | ||
| 35 | const msg = std.fmt.bufPrint(&buf, "wsclient: " ++ fmt ++ "\n", args) catch "wsclient: error\n"; | ||
| 36 | if (err_file) |f| f.writeAll(msg) catch {}; | ||
| 37 | std.debug.print("{s}", .{msg}); | ||
| 38 | std.process.exit(code); | ||
| 39 | } | ||
| 40 | |||
| 41 | /// C-style escapes, ptyclient's exact table (\xNN digit-by-digit so a | ||
| 42 | /// signed parse cannot sneak through). | ||
| 43 | fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 { | ||
| 44 | var out: std.ArrayList(u8) = .empty; | ||
| 45 | errdefer out.deinit(alloc); | ||
| 46 | var i: usize = 0; | ||
| 47 | while (i < s.len) : (i += 1) { | ||
| 48 | if (s[i] != '\\') { | ||
| 49 | try out.append(alloc, s[i]); | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | i += 1; | ||
| 53 | if (i >= s.len) return error.BadEscape; | ||
| 54 | switch (s[i]) { | ||
| 55 | 'n' => try out.append(alloc, '\n'), | ||
| 56 | 'r' => try out.append(alloc, '\r'), | ||
| 57 | 't' => try out.append(alloc, '\t'), | ||
| 58 | '\\' => try out.append(alloc, '\\'), | ||
| 59 | 'x' => { | ||
| 60 | if (i + 2 >= s.len) return error.BadEscape; | ||
| 61 | const hi = std.fmt.charToDigit(s[i + 1], 16) catch return error.BadEscape; | ||
| 62 | const lo = std.fmt.charToDigit(s[i + 2], 16) catch return error.BadEscape; | ||
| 63 | try out.append(alloc, hi * 16 + lo); | ||
| 64 | i += 2; | ||
| 65 | }, | ||
| 66 | else => return error.BadEscape, | ||
| 67 | } | ||
| 68 | } | ||
| 69 | return out.toOwnedSlice(alloc); | ||
| 70 | } | ||
| 71 | |||
| 72 | // --------------------------------------------------------------------------- | ||
| 73 | // RFC 6455, client side. The server side is std's; this is the ~60-line | ||
| 74 | // mirror image: masked sends, unmasked receives. | ||
| 75 | |||
| 76 | /// One client→server message: FIN + binary opcode, 4-byte mask, payload | ||
| 77 | /// XOR'd. Layout returned ready to write. | ||
| 78 | fn maskedMessage(alloc: std.mem.Allocator, payload: []const u8, mask: [4]u8) ![]u8 { | ||
| 79 | var out: std.ArrayList(u8) = .empty; | ||
| 80 | errdefer out.deinit(alloc); | ||
| 81 | try out.append(alloc, 0x82); // FIN | binary | ||
| 82 | if (payload.len <= 125) { | ||
| 83 | try out.append(alloc, 0x80 | @as(u8, @intCast(payload.len))); | ||
| 84 | } else if (payload.len <= 0xffff) { | ||
| 85 | try out.append(alloc, 0x80 | 126); | ||
| 86 | var b: [2]u8 = undefined; | ||
| 87 | std.mem.writeInt(u16, &b, @intCast(payload.len), .big); | ||
| 88 | try out.appendSlice(alloc, &b); | ||
| 89 | } else { | ||
| 90 | try out.append(alloc, 0x80 | 127); | ||
| 91 | var b: [8]u8 = undefined; | ||
| 92 | std.mem.writeInt(u64, &b, payload.len, .big); | ||
| 93 | try out.appendSlice(alloc, &b); | ||
| 94 | } | ||
| 95 | try out.appendSlice(alloc, &mask); | ||
| 96 | const start = out.items.len; | ||
| 97 | try out.appendSlice(alloc, payload); | ||
| 98 | for (out.items[start..], 0..) |*c, i| c.* ^= mask[i % 4]; | ||
| 99 | return out.toOwnedSlice(alloc); | ||
| 100 | } | ||
| 101 | |||
| 102 | /// Server→client frames accumulate here off the socket; whole messages | ||
| 103 | /// pop out. Control frames (ping/close) are handled by the caller via | ||
| 104 | /// the opcode. | ||
| 105 | const WsReader = struct { | ||
| 106 | buf: std.ArrayList(u8) = .empty, | ||
| 107 | |||
| 108 | const Msg = struct { opcode: u4, payload: []const u8, consumed: usize }; | ||
| 109 | |||
| 110 | /// Parse one whole frame from the front of the buffer, or null. | ||
| 111 | fn peek(self: *const WsReader) ?Msg { | ||
| 112 | const b = self.buf.items; | ||
| 113 | if (b.len < 2) return null; | ||
| 114 | const opcode: u4 = @truncate(b[0] & 0x0f); | ||
| 115 | const masked = b[1] & 0x80 != 0; | ||
| 116 | if (masked) return null; // server frames are never masked; treated as garbage by caller | ||
| 117 | var len: u64 = b[1] & 0x7f; | ||
| 118 | var off: usize = 2; | ||
| 119 | if (len == 126) { | ||
| 120 | if (b.len < 4) return null; | ||
| 121 | len = std.mem.readInt(u16, b[2..4], .big); | ||
| 122 | off = 4; | ||
| 123 | } else if (len == 127) { | ||
| 124 | if (b.len < 10) return null; | ||
| 125 | len = std.mem.readInt(u64, b[2..10], .big); | ||
| 126 | off = 10; | ||
| 127 | } | ||
| 128 | if (len > 64 * 1024 * 1024) return null; // absurd: caller dies on stall | ||
| 129 | if (b.len < off + len) return null; | ||
| 130 | return .{ .opcode = opcode, .payload = b[off .. off + @as(usize, @intCast(len))], .consumed = off + @as(usize, @intCast(len)) }; | ||
| 131 | } | ||
| 132 | |||
| 133 | fn consume(self: *WsReader, alloc: std.mem.Allocator, n: usize) void { | ||
| 134 | const rest = self.buf.items[n..]; | ||
| 135 | std.mem.copyForwards(u8, self.buf.items[0..rest.len], rest); | ||
| 136 | self.buf.shrinkRetainingCapacity(rest.len); | ||
| 137 | _ = alloc; | ||
| 138 | } | ||
| 139 | }; | ||
| 140 | |||
| 141 | // --------------------------------------------------------------------------- | ||
| 142 | |||
| 143 | const Client = struct { | ||
| 144 | alloc: std.mem.Allocator, | ||
| 145 | sock: std.posix.fd_t, | ||
| 146 | reader: WsReader = .{}, | ||
| 147 | eng: *Engine, | ||
| 148 | rep: Replica, | ||
| 149 | /// Last control-message state seen (the tile chrome's vocabulary). | ||
| 150 | last_state: [16]u8 = @splat(0), | ||
| 151 | last_state_len: usize = 0, | ||
| 152 | |||
| 153 | fn sendMessage(self: *Client, payload: []const u8) void { | ||
| 154 | var mask: [4]u8 = undefined; | ||
| 155 | std.crypto.random.bytes(&mask); | ||
| 156 | const msg = maskedMessage(self.alloc, payload, mask) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 157 | defer self.alloc.free(msg); | ||
| 158 | writeAll(self.sock, msg) catch fatal(EXIT_DIED, "hub hung up mid-send", .{}); | ||
| 159 | } | ||
| 160 | |||
| 161 | fn sendFrame(self: *Client, t: u8, payload: []const u8) void { | ||
| 162 | var out: std.ArrayList(u8) = .empty; | ||
| 163 | defer out.deinit(self.alloc); | ||
| 164 | out.append(self.alloc, 0x00) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 165 | out.append(self.alloc, t) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 166 | var lenb: [4]u8 = undefined; | ||
| 167 | std.mem.writeInt(u32, &lenb, @intCast(payload.len), .little); | ||
| 168 | out.appendSlice(self.alloc, &lenb) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 169 | out.appendSlice(self.alloc, payload) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 170 | self.sendMessage(out.items); | ||
| 171 | } | ||
| 172 | |||
| 173 | /// Pump whatever is on the socket into the reader and apply every | ||
| 174 | /// whole message. Returns false when the hub hung up. `wait_ms` is | ||
| 175 | /// one poll's patience, not a deadline. | ||
| 176 | fn pump(self: *Client, wait_ms: i32) bool { | ||
| 177 | var fds = [_]std.posix.pollfd{ | ||
| 178 | .{ .fd = self.sock, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 179 | }; | ||
| 180 | const n = std.posix.poll(&fds, wait_ms) catch return false; | ||
| 181 | if (n > 0 and fds[0].revents != 0) { | ||
| 182 | var buf: [64 * 1024]u8 = undefined; | ||
| 183 | const got = std.posix.read(self.sock, &buf) catch return false; | ||
| 184 | if (got == 0) return false; | ||
| 185 | self.reader.buf.appendSlice(self.alloc, buf[0..got]) catch return false; | ||
| 186 | } | ||
| 187 | while (self.reader.peek()) |msg| { | ||
| 188 | self.handle(msg); | ||
| 189 | self.reader.consume(self.alloc, msg.consumed); | ||
| 190 | } | ||
| 191 | return true; | ||
| 192 | } | ||
| 193 | |||
| 194 | fn handle(self: *Client, msg: WsReader.Msg) void { | ||
| 195 | switch (msg.opcode) { | ||
| 196 | 0x8 => fatal(EXIT_DIED, "hub sent close", .{}), | ||
| 197 | 0x9 => return, // ping: the hub never sends one; ignore rather than die | ||
| 198 | 0x1, 0x2 => {}, | ||
| 199 | else => return, | ||
| 200 | } | ||
| 201 | const data = msg.payload; | ||
| 202 | if (data.len < 1) return; | ||
| 203 | if (data[0] == 0x01) { | ||
| 204 | // {"state":"..."} — extracted textually; the vocabulary is | ||
| 205 | // closed and the producer is ours. | ||
| 206 | const json = data[1..]; | ||
| 207 | const k = "\"state\":\""; | ||
| 208 | if (std.mem.indexOf(u8, json, k)) |i| { | ||
| 209 | const rest = json[i + k.len ..]; | ||
| 210 | const end = std.mem.indexOfScalar(u8, rest, '"') orelse return; | ||
| 211 | const state = rest[0..end]; | ||
| 212 | const n = @min(state.len, self.last_state.len); | ||
| 213 | @memcpy(self.last_state[0..n], state[0..n]); | ||
| 214 | self.last_state_len = n; | ||
| 215 | } | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | if (data[0] != 0x00 or data.len < 1 + proto.frame_header_len) return; | ||
| 219 | const t = data[1]; | ||
| 220 | const plen = std.mem.readInt(u32, data[2..6][0..4], .little); | ||
| 221 | if (data.len - 6 != plen) return; | ||
| 222 | const payload = data[6..]; | ||
| 223 | const snapshot: u8 = @intFromEnum(proto.MsgType.snapshot); | ||
| 224 | const delta: u8 = @intFromEnum(proto.MsgType.delta); | ||
| 225 | if (t == snapshot or t == delta) { | ||
| 226 | const applied = self.rep.apply(@enumFromInt(t), payload) catch return; | ||
| 227 | if (applied == .resync) { | ||
| 228 | // Mirror the browser: a garbled delta re-attaches fresh. | ||
| 229 | var att = proto.encodeAttach(self.rep.grid.cols, self.rep.grid.rows, 0, 0); | ||
| 230 | self.sendFrame(@intFromEnum(proto.MsgType.attach), &att); | ||
| 231 | } | ||
| 232 | } | ||
| 233 | // Everything else (exit_status, pty_mode, scrollback) is visible | ||
| 234 | // in --err via the state machinery when a scenario needs it. | ||
| 235 | } | ||
| 236 | |||
| 237 | fn stateIs(self: *const Client, want: []const u8) bool { | ||
| 238 | return std.mem.eql(u8, self.last_state[0..self.last_state_len], want); | ||
| 239 | } | ||
| 240 | }; | ||
| 241 | |||
| 242 | fn writeAll(fd: std.posix.fd_t, data: []const u8) !void { | ||
| 243 | var i: usize = 0; | ||
| 244 | while (i < data.len) i += try std.posix.write(fd, data[i..]); | ||
| 245 | } | ||
| 246 | |||
| 247 | fn nowMs() i64 { | ||
| 248 | return std.time.milliTimestamp(); | ||
| 249 | } | ||
| 250 | |||
| 251 | // --------------------------------------------------------------------------- | ||
| 252 | |||
| 253 | pub fn main() !void { | ||
| 254 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 255 | defer _ = gpa.deinit(); | ||
| 256 | const alloc = gpa.allocator(); | ||
| 257 | |||
| 258 | const args = try std.process.argsAlloc(alloc); | ||
| 259 | defer std.process.argsFree(alloc, args); | ||
| 260 | |||
| 261 | var port: ?u16 = null; | ||
| 262 | var tile: usize = 0; | ||
| 263 | var out_path: ?[]const u8 = null; | ||
| 264 | var err_path: ?[]const u8 = null; | ||
| 265 | var origin: ?[]const u8 = null; | ||
| 266 | |||
| 267 | var i: usize = 1; | ||
| 268 | while (i < args.len) : (i += 1) { | ||
| 269 | const a = args[i]; | ||
| 270 | if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { | ||
| 271 | i += 1; | ||
| 272 | port = std.fmt.parseInt(u16, args[i], 10) catch fatal(EXIT_USAGE, "bad --port", .{}); | ||
| 273 | } else if (std.mem.eql(u8, a, "--tile") and i + 1 < args.len) { | ||
| 274 | i += 1; | ||
| 275 | tile = std.fmt.parseInt(usize, args[i], 10) catch fatal(EXIT_USAGE, "bad --tile", .{}); | ||
| 276 | } else if (std.mem.eql(u8, a, "--out") and i + 1 < args.len) { | ||
| 277 | i += 1; | ||
| 278 | out_path = args[i]; | ||
| 279 | } else if (std.mem.eql(u8, a, "--err") and i + 1 < args.len) { | ||
| 280 | i += 1; | ||
| 281 | err_path = args[i]; | ||
| 282 | } else if (std.mem.eql(u8, a, "--origin") and i + 1 < args.len) { | ||
| 283 | i += 1; | ||
| 284 | origin = args[i]; | ||
| 285 | } else { | ||
| 286 | fatal(EXIT_USAGE, "unknown arg {s}", .{a}); | ||
| 287 | } | ||
| 288 | } | ||
| 289 | const p = port orelse fatal(EXIT_USAGE, "--port required", .{}); | ||
| 290 | const op = out_path orelse fatal(EXIT_USAGE, "--out required", .{}); | ||
| 291 | const ep = err_path orelse fatal(EXIT_USAGE, "--err required", .{}); | ||
| 292 | err_file = std.fs.cwd().createFile(ep, .{}) catch fatal(EXIT_USAGE, "cannot open --err", .{}); | ||
| 293 | |||
| 294 | // --- TCP + upgrade --- | ||
| 295 | const addr = std.net.Address.parseIp("127.0.0.1", p) catch unreachable; | ||
| 296 | const stream = std.net.tcpConnectToAddress(addr) catch | ||
| 297 | fatal(EXIT_DIED, "cannot connect to 127.0.0.1:{d}", .{p}); | ||
| 298 | const sock = stream.handle; | ||
| 299 | |||
| 300 | var key_raw: [16]u8 = undefined; | ||
| 301 | std.crypto.random.bytes(&key_raw); | ||
| 302 | var key_b64: [24]u8 = undefined; | ||
| 303 | _ = std.base64.standard.Encoder.encode(&key_b64, &key_raw); | ||
| 304 | |||
| 305 | const default_origin = try std.fmt.allocPrint(alloc, "http://127.0.0.1:{d}", .{p}); | ||
| 306 | defer alloc.free(default_origin); | ||
| 307 | const req = try std.fmt.allocPrint(alloc, | ||
| 308 | "GET /ws/{d} HTTP/1.1\r\n" ++ | ||
| 309 | "host: 127.0.0.1:{d}\r\n" ++ | ||
| 310 | "connection: upgrade\r\n" ++ | ||
| 311 | "upgrade: websocket\r\n" ++ | ||
| 312 | "sec-websocket-version: 13\r\n" ++ | ||
| 313 | "sec-websocket-key: {s}\r\n" ++ | ||
| 314 | "origin: {s}\r\n\r\n", .{ tile, p, key_b64, origin orelse default_origin }); | ||
| 315 | defer alloc.free(req); | ||
| 316 | writeAll(sock, req) catch fatal(EXIT_DIED, "hub hung up during upgrade", .{}); | ||
| 317 | |||
| 318 | // Read the response head. On anything but 101 print the status line | ||
| 319 | // and exit 4 — the wrong-Origin scenario asserts exactly this. | ||
| 320 | var head: std.ArrayList(u8) = .empty; | ||
| 321 | defer head.deinit(alloc); | ||
| 322 | while (std.mem.indexOf(u8, head.items, "\r\n\r\n") == null) { | ||
| 323 | if (head.items.len > 16 * 1024) fatal(EXIT_DIED, "oversize upgrade response", .{}); | ||
| 324 | var b: [1024]u8 = undefined; | ||
| 325 | const n = std.posix.read(sock, &b) catch fatal(EXIT_DIED, "read failed during upgrade", .{}); | ||
| 326 | if (n == 0) fatal(EXIT_DIED, "hub closed during upgrade", .{}); | ||
| 327 | try head.appendSlice(alloc, b[0..n]); | ||
| 328 | } | ||
| 329 | const head_end = std.mem.indexOf(u8, head.items, "\r\n\r\n").? + 4; | ||
| 330 | const status_line = head.items[0 .. std.mem.indexOf(u8, head.items, "\r\n").?]; | ||
| 331 | if (std.mem.indexOf(u8, status_line, "101") == null) | ||
| 332 | fatal(EXIT_DIED, "upgrade refused: {s}", .{status_line}); | ||
| 333 | // The accept key must be OUR key's digest — a hub echoing a canned | ||
| 334 | // value would pass anything else this fixture checks. | ||
| 335 | var sha = std.crypto.hash.Sha1.init(.{}); | ||
| 336 | sha.update(&key_b64); | ||
| 337 | sha.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); | ||
| 338 | var digest: [20]u8 = undefined; | ||
| 339 | sha.final(&digest); | ||
| 340 | var accept_b64: [28]u8 = undefined; | ||
| 341 | _ = std.base64.standard.Encoder.encode(&accept_b64, &digest); | ||
| 342 | if (std.mem.indexOf(u8, head.items[0..head_end], &accept_b64) == null) | ||
| 343 | fatal(EXIT_DIED, "sec-websocket-accept mismatch", .{}); | ||
| 344 | |||
| 345 | // --- replica + client --- | ||
| 346 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 347 | defer eng.deinit(); | ||
| 348 | var cl = Client{ .alloc = alloc, .sock = sock, .eng = eng, .rep = Replica.init(alloc, eng) }; | ||
| 349 | defer cl.reader.buf.deinit(alloc); | ||
| 350 | // Bytes past the head are the first WS frames. | ||
| 351 | try cl.reader.buf.appendSlice(alloc, head.items[head_end..]); | ||
| 352 | |||
| 353 | // --- script loop --- | ||
| 354 | var stdin_buf: std.ArrayList(u8) = .empty; | ||
| 355 | defer stdin_buf.deinit(alloc); | ||
| 356 | var rbuf: [4096]u8 = undefined; | ||
| 357 | while (true) { | ||
| 358 | const n = std.posix.read(std.posix.STDIN_FILENO, &rbuf) catch break; | ||
| 359 | if (n == 0) break; | ||
| 360 | try stdin_buf.appendSlice(alloc, rbuf[0..n]); | ||
| 361 | } | ||
| 362 | |||
| 363 | var lines = std.mem.splitScalar(u8, stdin_buf.items, '\n'); | ||
| 364 | while (lines.next()) |raw| { | ||
| 365 | const line = std.mem.trim(u8, raw, " \t\r"); | ||
| 366 | if (line.len == 0 or line[0] == '#') continue; | ||
| 367 | const sp = std.mem.indexOfScalar(u8, line, ' '); | ||
| 368 | const verb = if (sp) |s| line[0..s] else line; | ||
| 369 | const rest = if (sp) |s| line[s + 1 ..] else ""; | ||
| 370 | |||
| 371 | if (std.mem.eql(u8, verb, "attach") or std.mem.eql(u8, verb, "attachfresh")) { | ||
| 372 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 373 | const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{}); | ||
| 374 | const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{}); | ||
| 375 | const fresh = std.mem.eql(u8, verb, "attachfresh"); | ||
| 376 | const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else cl.rep.attachArgs(); | ||
| 377 | const att = proto.encodeAttach(cols, rows, q.have_seq, q.have_epoch); | ||
| 378 | cl.sendFrame(@intFromEnum(proto.MsgType.attach), &att); | ||
| 379 | } else if (std.mem.eql(u8, verb, "send")) { | ||
| 380 | const bytes = decodeEscapes(alloc, rest) catch fatal(EXIT_USAGE, "bad escape in send", .{}); | ||
| 381 | defer alloc.free(bytes); | ||
| 382 | cl.sendFrame(@intFromEnum(proto.MsgType.input), bytes); | ||
| 383 | } else if (std.mem.eql(u8, verb, "resize")) { | ||
| 384 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 385 | const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "resize C R", .{}), 10) catch fatal(EXIT_USAGE, "resize C R", .{}); | ||
| 386 | const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "resize C R", .{}), 10) catch fatal(EXIT_USAGE, "resize C R", .{}); | ||
| 387 | const sz = proto.encodeSize(cols, rows); | ||
| 388 | cl.sendFrame(@intFromEnum(proto.MsgType.resize), &sz); | ||
| 389 | } else if (std.mem.eql(u8, verb, "expectgrid")) { | ||
| 390 | const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse fatal(EXIT_USAGE, "expectgrid NEEDLE MS", .{}); | ||
| 391 | if (last == 0) fatal(EXIT_USAGE, "empty needle", .{}); | ||
| 392 | const ms = std.fmt.parseInt(i64, rest[last + 1 ..], 10) catch fatal(EXIT_USAGE, "bad deadline", .{}); | ||
| 393 | const needle = decodeEscapes(alloc, rest[0..last]) catch fatal(EXIT_USAGE, "bad escape", .{}); | ||
| 394 | defer alloc.free(needle); | ||
| 395 | const deadline = nowMs() + ms; | ||
| 396 | while (true) { | ||
| 397 | const dump = cl.rep.eng.dumpPlain(alloc) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 398 | const hit = std.mem.indexOf(u8, dump, needle) != null; | ||
| 399 | alloc.free(@constCast(dump)); | ||
| 400 | if (hit) break; | ||
| 401 | if (nowMs() >= deadline) fatal(EXIT_TIMEOUT, "expectgrid '{s}' timed out", .{needle}); | ||
| 402 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during expectgrid", .{}); | ||
| 403 | } | ||
| 404 | } else if (std.mem.eql(u8, verb, "expectstate")) { | ||
| 405 | const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse fatal(EXIT_USAGE, "expectstate STATE MS", .{}); | ||
| 406 | const ms = std.fmt.parseInt(i64, rest[last + 1 ..], 10) catch fatal(EXIT_USAGE, "bad deadline", .{}); | ||
| 407 | const want = rest[0..last]; | ||
| 408 | const deadline = nowMs() + ms; | ||
| 409 | while (!cl.stateIs(want)) { | ||
| 410 | if (nowMs() >= deadline) fatal(EXIT_TIMEOUT, "expectstate '{s}' timed out (at '{s}')", .{ want, cl.last_state[0..cl.last_state_len] }); | ||
| 411 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during expectstate", .{}); | ||
| 412 | } | ||
| 413 | } else if (std.mem.eql(u8, verb, "settle")) { | ||
| 414 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 415 | const quiet = std.fmt.parseInt(i64, it.next() orelse fatal(EXIT_USAGE, "settle QUIET MS", .{}), 10) catch fatal(EXIT_USAGE, "settle QUIET MS", .{}); | ||
| 416 | const ms = std.fmt.parseInt(i64, it.next() orelse fatal(EXIT_USAGE, "settle QUIET MS", .{}), 10) catch fatal(EXIT_USAGE, "settle QUIET MS", .{}); | ||
| 417 | const deadline = nowMs() + ms; | ||
| 418 | var last_traffic = nowMs(); | ||
| 419 | while (nowMs() - last_traffic < quiet) { | ||
| 420 | if (nowMs() >= deadline) break; | ||
| 421 | const before = cl.rep.last_seq; | ||
| 422 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during settle", .{}); | ||
| 423 | if (cl.rep.last_seq != before) last_traffic = nowMs(); | ||
| 424 | } | ||
| 425 | } else if (std.mem.eql(u8, verb, "dumpexit")) { | ||
| 426 | const dump = cl.rep.eng.dumpPlain(alloc) catch fatal(EXIT_USAGE, "oom", .{}); | ||
| 427 | defer alloc.free(@constCast(dump)); | ||
| 428 | const out = std.fs.cwd().createFile(op, .{}) catch fatal(EXIT_USAGE, "cannot open --out", .{}); | ||
| 429 | defer out.close(); | ||
| 430 | out.writeAll(dump) catch fatal(EXIT_USAGE, "write --out failed", .{}); | ||
| 431 | // `muxd dump` ends its output with a newline; match it so the | ||
| 432 | // e2e diff compares grids, not file conventions. | ||
| 433 | if (dump.len == 0 or dump[dump.len - 1] != '\n') | ||
| 434 | out.writeAll("\n") catch fatal(EXIT_USAGE, "write --out failed", .{}); | ||
| 435 | stream.close(); | ||
| 436 | return; | ||
| 437 | } else { | ||
| 438 | fatal(EXIT_USAGE, "unknown verb {s}", .{verb}); | ||
| 439 | } | ||
| 440 | } | ||
| 441 | fatal(EXIT_USAGE, "script ended without dumpexit", .{}); | ||
| 442 | } | ||
| 443 | |||
| 444 | // --------------------------------------------------------------------------- | ||
| 445 | |||
| 446 | test "masked message: header layout and mask application, all three length forms" { | ||
| 447 | const alloc = std.testing.allocator; | ||
| 448 | const mask = [4]u8{ 0xaa, 0xbb, 0xcc, 0xdd }; | ||
| 449 | |||
| 450 | const small = try maskedMessage(alloc, "hi", mask); | ||
| 451 | defer alloc.free(small); | ||
| 452 | try std.testing.expectEqual(@as(u8, 0x82), small[0]); // FIN | binary | ||
| 453 | try std.testing.expectEqual(@as(u8, 0x80 | 2), small[1]); // masked, len 2 | ||
| 454 | try std.testing.expectEqualSlices(u8, &mask, small[2..6]); | ||
| 455 | try std.testing.expectEqual(@as(u8, 'h' ^ 0xaa), small[6]); | ||
| 456 | try std.testing.expectEqual(@as(u8, 'i' ^ 0xbb), small[7]); | ||
| 457 | |||
| 458 | const mid_payload = [_]u8{0x55} ** 300; | ||
| 459 | const mid = try maskedMessage(alloc, &mid_payload, mask); | ||
| 460 | defer alloc.free(mid); | ||
| 461 | try std.testing.expectEqual(@as(u8, 0x80 | 126), mid[1]); | ||
| 462 | try std.testing.expectEqual(@as(u16, 300), std.mem.readInt(u16, mid[2..4], .big)); | ||
| 463 | try std.testing.expectEqual(@as(u8, 0x55 ^ 0xcc), mid[4 + 4 + 2]); // idx 2 in payload → mask[2] | ||
| 464 | |||
| 465 | const big_payload = try alloc.alloc(u8, 70 * 1024); | ||
| 466 | defer alloc.free(big_payload); | ||
| 467 | @memset(big_payload, 1); | ||
| 468 | const big = try maskedMessage(alloc, big_payload, mask); | ||
| 469 | defer alloc.free(big); | ||
| 470 | try std.testing.expectEqual(@as(u8, 0x80 | 127), big[1]); | ||
| 471 | try std.testing.expectEqual(@as(u64, 70 * 1024), std.mem.readInt(u64, big[2..10], .big)); | ||
| 472 | } | ||
| 473 | |||
| 474 | test "ws reader: split delivery reassembles; server frames arrive unmasked" { | ||
| 475 | const alloc = std.testing.allocator; | ||
| 476 | var r = WsReader{}; | ||
| 477 | defer r.buf.deinit(alloc); | ||
| 478 | |||
| 479 | // A 130-byte binary message → 126-form header, unmasked. | ||
| 480 | var payload: [130]u8 = undefined; | ||
| 481 | for (&payload, 0..) |*c, i| c.* = @intCast(i & 0xff); | ||
| 482 | var hdr = [_]u8{ 0x82, 126, 0, 130 }; | ||
| 483 | // Feed in three ragged slices; nothing pops until it is whole. | ||
| 484 | try r.buf.appendSlice(alloc, hdr[0..2]); | ||
| 485 | try std.testing.expect(r.peek() == null); | ||
| 486 | try r.buf.appendSlice(alloc, hdr[2..]); | ||
| 487 | try r.buf.appendSlice(alloc, payload[0..70]); | ||
| 488 | try std.testing.expect(r.peek() == null); | ||
| 489 | try r.buf.appendSlice(alloc, payload[70..]); | ||
| 490 | const msg = r.peek().?; | ||
| 491 | try std.testing.expectEqual(@as(u4, 2), msg.opcode); | ||
| 492 | try std.testing.expectEqualSlices(u8, &payload, msg.payload); | ||
| 493 | r.consume(alloc, msg.consumed); | ||
| 494 | try std.testing.expectEqual(@as(usize, 0), r.buf.items.len); | ||
| 495 | } | ||
| 496 | |||
| 497 | test "the dump this exits with is the daemon's own dump format" { | ||
| 498 | // dumpexit writes Engine.dumpPlain — the SAME function muxd dump | ||
| 499 | // prints through — so the e2e diff cannot fail on formatting. The | ||
| 500 | // pin: feed both a daemon-side engine and this fixture's replica the | ||
| 501 | // same snapshot; byte-identical dumps. | ||
| 502 | const alloc = std.testing.allocator; | ||
| 503 | var daemon_eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 504 | defer daemon_eng.deinit(); | ||
| 505 | daemon_eng.feed("convergence\r\nby construction"); | ||
| 506 | |||
| 507 | var fixture_eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 508 | defer fixture_eng.deinit(); | ||
| 509 | var rep = Replica.init(alloc, fixture_eng); | ||
| 510 | const state = try daemon_eng.dumpState(alloc); | ||
| 511 | defer alloc.free(state); | ||
| 512 | var payload = try alloc.alloc(u8, proto.snapshot_prefix_len + state.len); | ||
| 513 | defer alloc.free(payload); | ||
| 514 | proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], .{ | ||
| 515 | .seq = 1, | ||
| 516 | .history_rows = 0, | ||
| 517 | .cols = 80, | ||
| 518 | .rows = 24, | ||
| 519 | .epoch = 1, | ||
| 520 | }); | ||
| 521 | @memcpy(payload[proto.snapshot_prefix_len..], state); | ||
| 522 | _ = try rep.apply(.snapshot, payload); | ||
| 523 | |||
| 524 | const a = try daemon_eng.dumpPlain(alloc); | ||
| 525 | defer alloc.free(a); | ||
| 526 | const b = try fixture_eng.dumpPlain(alloc); | ||
| 527 | defer alloc.free(b); | ||
| 528 | try std.testing.expectEqualStrings(a, b); | ||
| 529 | } | ||