a73x

123bf352

fix(test): the wall tile's stand-in stays 1x1 across a resync

a73x   2026-08-13 14:46

Commit message
fix(test): the wall tile's stand-in stays 1x1 across a resync

M17 simplify round, test batch: the two scripted e2e fixtures.

The Important: wsclient's resync path re-attached with rep.grid — the
AUTHORITATIVE grid it had just learned from the snapshot — so a 1x1
passive stand-in re-attached at 80x24, moved the shared grid, and
repainted every other client. That is the exact opposite of the
passivity contract scenario (a) exists to pin, and the fixture pinning
it was the thing that broke it. Every attach now goes through one
sendAttach that records and re-quotes the TILE's size, which is how the
browser is structured too (mux.js routes every attach through its own
sendAttach). Pinned at the WS write seam: the test drives a real resync
over a pipe and decodes the masked frame the fixture wrote — it reads
80 before the fix, 1 after.

The minors, all in wsclient:

  - settle gave up silently on its deadline, reporting a quiesced
    session it never observed; it now exits 3 with the state and seq,
    matching ptyclient's settle.
  - expectstate accepted an empty needle, which last_state matches
    before any control message arrives — refused, as expectgrid and
    ptyclient's expect already do.
  - attach/resize silently dropped a third token; refused, matching
    ptyclient's resize and its pin.
  - a flag in final position reported "unknown arg --port" instead of
    "--port needs a value", sending the reader after a typo in a flag
    spelled correctly. ptyclient's loop is the model.
  - Client.eng was written and never read; WsReader.consume took an
    allocator it discarded. Both gone.

And the simplifications:

  - test/script.zig: the escape table and the three exit codes, one
    copy, imported by both fixtures. The point is not the 62 duplicated
    lines it removes but the four pins wsclient inherits with them —
    including \x+1, the sign-injection case its own copy had the
    comment for and no test. It leads the test loop: instant,
    socket-free, and the foundation both fixtures stand on.
  - build.zig: five identical wasm createModule blocks become wasmMod
    calls. ReleaseSmall is the field that must not drift between them,
    so it is now stated once.

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

build.zig
Old New
@@ -40,6 +40,19 @@ fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct {
40 }; 40 };
41 } 41 }
42 42
43 /// One wasm-side twin of a native module: same source, the wasm32 target,
44 /// and ReleaseSmall — never `optimize`, because the artifact is embedded
45 /// into muxweb and its Debug build is 3.7MB against ReleaseSmall's 345KB.
46 /// The five of them differ only in path, and the optimize mode is the one
47 /// field that must not drift between them.
48 fn wasmMod(b: *std.Build, wasm_target: std.Build.ResolvedTarget, path: []const u8) *std.Build.Module {
49 return b.createModule(.{
50 .root_source_file = b.path(path),
51 .target = wasm_target,
52 .optimize = .ReleaseSmall,
53 });
54 }
55
43 /// Give a compile step the QUIC stack: header path, library path, and the 56 /// Give a compile step the QUIC stack: header path, library path, and the
44 /// three archives in dependency order (crypto backend, core, TLS). 57 /// three archives in dependency order (crypto backend, core, TLS).
45 /// 58 ///
@@ -347,6 +360,15 @@ pub fn build(b: *std.Build) void {
347 }); 360 });
348 render_mod.addImport("engine", engine_mod); 361 render_mod.addImport("engine", engine_mod);
349 362
363 // What the two scripted fixtures share: the escape table and the exit
364 // codes. One copy, so ptyclient and wsclient cannot disagree about
365 // what a scenario's heredoc sent.
366 const script_mod = b.createModule(.{
367 .root_source_file = b.path("test/script.zig"),
368 .target = target,
369 .optimize = optimize,
370 });
371
350 // The pty-driving e2e fixture: real client on a pty slave, scripted 372 // The pty-driving e2e fixture: real client on a pty slave, scripted
351 // from stdin (M12). Imports pty so the product's own module is the one 373 // from stdin (M12). Imports pty so the product's own module is the one
352 // under it. 374 // under it.
@@ -357,6 +379,7 @@ pub fn build(b: *std.Build) void {
357 .link_libc = true, 379 .link_libc = true,
358 }); 380 });
359 ptyclient_mod.addImport("pty", pty_mod); 381 ptyclient_mod.addImport("pty", pty_mod);
382 ptyclient_mod.addImport("script", script_mod);
360 383
361 const exe_mod = b.createModule(.{ 384 const exe_mod = b.createModule(.{
362 .root_source_file = b.path("src/main.zig"), 385 .root_source_file = b.path("src/main.zig"),
@@ -435,36 +458,16 @@ pub fn build(b: *std.Build) void {
435 .target = wasm_target, 458 .target = wasm_target,
436 .optimize = .ReleaseSmall, 459 .optimize = .ReleaseSmall,
437 }); 460 });
438 const engine_wasm_mod = b.createModule(.{ 461 const engine_wasm_mod = wasmMod(b, wasm_target, "src/engine.zig");
439 .root_source_file = b.path("src/engine.zig"),
440 .target = wasm_target,
441 .optimize = .ReleaseSmall,
442 });
443 if (ghostty_wasm_dep) |dep| { 462 if (ghostty_wasm_dep) |dep| {
444 engine_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); 463 engine_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
445 } 464 }
446 const protocol_wasm_mod = b.createModule(.{ 465 const protocol_wasm_mod = wasmMod(b, wasm_target, "src/protocol.zig");
447 .root_source_file = b.path("src/protocol.zig"), 466 const replica_wasm_mod = wasmMod(b, wasm_target, "src/replica.zig");
448 .target = wasm_target,
449 .optimize = .ReleaseSmall,
450 });
451 const replica_wasm_mod = b.createModule(.{
452 .root_source_file = b.path("src/replica.zig"),
453 .target = wasm_target,
454 .optimize = .ReleaseSmall,
455 });
456 replica_wasm_mod.addImport("engine", engine_wasm_mod); 467 replica_wasm_mod.addImport("engine", engine_wasm_mod);
457 replica_wasm_mod.addImport("protocol", protocol_wasm_mod); 468 replica_wasm_mod.addImport("protocol", protocol_wasm_mod);
458 const keymap_wasm_mod = b.createModule(.{ 469 const keymap_wasm_mod = wasmMod(b, wasm_target, "src/keymap.zig");
459 .root_source_file = b.path("src/keymap.zig"), 470 const wasm_core_mod = wasmMod(b, wasm_target, "src/wasm_core.zig");
460 .target = wasm_target,
461 .optimize = .ReleaseSmall,
462 });
463 const wasm_core_mod = b.createModule(.{
464 .root_source_file = b.path("src/wasm_core.zig"),
465 .target = wasm_target,
466 .optimize = .ReleaseSmall,
467 });
468 wasm_core_mod.addImport("engine", engine_wasm_mod); 471 wasm_core_mod.addImport("engine", engine_wasm_mod);
469 wasm_core_mod.addImport("protocol", protocol_wasm_mod); 472 wasm_core_mod.addImport("protocol", protocol_wasm_mod);
470 wasm_core_mod.addImport("replica", replica_wasm_mod); 473 wasm_core_mod.addImport("replica", replica_wasm_mod);
@@ -491,6 +494,7 @@ pub fn build(b: *std.Build) void {
491 wsclient_mod.addImport("engine", engine_mod); 494 wsclient_mod.addImport("engine", engine_mod);
492 wsclient_mod.addImport("replica", replica_mod); 495 wsclient_mod.addImport("replica", replica_mod);
493 wsclient_mod.addImport("protocol", protocol_mod); 496 wsclient_mod.addImport("protocol", protocol_mod);
497 wsclient_mod.addImport("script", script_mod);
494 const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod }); 498 const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod });
495 wsclient_exe.use_llvm = true; 499 wsclient_exe.use_llvm = true;
496 wsclient_exe.use_lld = true; 500 wsclient_exe.use_lld = true;
@@ -531,7 +535,11 @@ pub fn build(b: *std.Build) void {
531 // absence here was a live hazard recorded in decisions.md — muxd's 535 // absence here was a live hazard recorded in decisions.md — muxd's
532 // entrypoint could grow tests that silently never ran, exactly as 536 // entrypoint could grow tests that silently never ran, exactly as
533 // mux_main.zig's five did before it was added. 537 // mux_main.zig's five did before it was added.
534 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| { 538 //
539 // script_mod leads for the same order-is-legibility reason: its tests
540 // are instant and allocation-only, and the escape pins they carry are
541 // the ones both fixtures inherit.
542 for ([_]*std.Build.Module{ script_mod, 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| {
535 const t = b.addTest(.{ .root_module = mod }); 543 const t = b.addTest(.{ .root_module = mod });
536 t.use_llvm = true; 544 t.use_llvm = true;
537 t.use_lld = true; 545 t.use_lld = true;
test/ptyclient.zig
Old New
@@ -8,39 +8,10 @@
8 //! Spec: docs/superpowers/specs/2026-08-10-m12-ptyclient-design.md. 8 //! Spec: docs/superpowers/specs/2026-08-10-m12-ptyclient-design.md.
9 const std = @import("std"); 9 const std = @import("std");
10 const Pty = @import("pty").Pty; 10 const Pty = @import("pty").Pty;
11 11 // The script dialect this fixture and wsclient both speak: the escape
12 /// C-style escapes: \xNN, \n, \r, \t, \\. Anything else after a backslash 12 // table and the exit codes (test/script.zig).
13 /// is an error — a typo'd escape must fail loudly, not send mystery bytes. 13 const script = @import("script");
14 fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 { 14 const decodeEscapes = script.decodeEscapes;
15 var out: std.ArrayList(u8) = .empty;
16 errdefer out.deinit(alloc);
17 var i: usize = 0;
18 while (i < s.len) : (i += 1) {
19 if (s[i] != '\\') {
20 try out.append(alloc, s[i]);
21 continue;
22 }
23 i += 1;
24 if (i >= s.len) return error.BadEscape;
25 switch (s[i]) {
26 'n' => try out.append(alloc, '\n'),
27 'r' => try out.append(alloc, '\r'),
28 't' => try out.append(alloc, '\t'),
29 '\\' => try out.append(alloc, '\\'),
30 'x' => {
31 if (i + 2 >= s.len) return error.BadEscape;
32 // Digit by digit rather than parseInt: parseInt accepts a
33 // sign, so `\x+1` would quietly decode as 0x01.
34 const hi = std.fmt.charToDigit(s[i + 1], 16) catch return error.BadEscape;
35 const lo = std.fmt.charToDigit(s[i + 2], 16) catch return error.BadEscape;
36 try out.append(alloc, hi * 16 + lo);
37 i += 2;
38 },
39 else => return error.BadEscape,
40 }
41 }
42 return out.toOwnedSlice(alloc);
43 }
44 15
45 /// Accumulates everything read off the master and matches needles with 16 /// Accumulates everything read off the master and matches needles with
46 /// expect(1) semantics: the search starts at a cursor, and a match 17 /// expect(1) semantics: the search starts at a cursor, and a match
@@ -128,14 +99,12 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
128 return error.BadVerb; 99 return error.BadVerb;
129 } 100 }
130 101
131 // Exit codes, distinct so a scenario failure names its layer: 102 // Exit codes are script.zig's, shared with wsclient so a scenario reads
132 // 2 usage / setup failure 103 // the same number the same way whichever fixture produced it. Here "the
133 // 3 expect deadline passed 104 // far side died" means the client on the pty slave.
134 // 4 client exited before the script finished 105 const EXIT_USAGE = script.EXIT_USAGE;
135 // otherwise: the client's own exit status (waitexit propagates it) 106 const EXIT_TIMEOUT = script.EXIT_TIMEOUT;
136 const EXIT_USAGE: u8 = 2; 107 const EXIT_CHILD_DIED = script.EXIT_DIED;
137 const EXIT_TIMEOUT: u8 = 3;
138 const EXIT_CHILD_DIED: u8 = 4;
139 108
140 fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn { 109 fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn {
141 std.debug.print("ptyclient: " ++ fmt ++ "\n", args); 110 std.debug.print("ptyclient: " ++ fmt ++ "\n", args);
@@ -401,28 +370,6 @@ pub fn main() !void {
401 } 370 }
402 } 371 }
403 372
404 test "decodeEscapes: named, hex, literal backslash" {
405 const alloc = std.testing.allocator;
406 const cases = [_]struct { in: []const u8, want: []const u8 }{
407 .{ .in = "hello\\n", .want = "hello\n" },
408 .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" },
409 .{ .in = "a\\\\b", .want = "a\\b" },
410 .{ .in = "\\x04", .want = "\x04" },
411 .{ .in = "cr\\r", .want = "cr\r" },
412 .{ .in = "tab\\there", .want = "tab\there" },
413 };
414 for (cases) |cs| {
415 const got = try decodeEscapes(alloc, cs.in);
416 defer alloc.free(got);
417 try std.testing.expectEqualSlices(u8, cs.want, got);
418 }
419 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q"));
420 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1"));
421 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trailing\\"));
422 // parseInt would take the sign and decode this as 0x01.
423 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\x+1"));
424 }
425
426 test "Expecter: a needle split across two feeds still matches" { 373 test "Expecter: a needle split across two feeds still matches" {
427 const alloc = std.testing.allocator; 374 const alloc = std.testing.allocator;
428 var e: Expecter = .{}; 375 var e: Expecter = .{};
test/script.zig
Old New
@@ -0,0 +1,74 @@
1 //! What the two scripted e2e fixtures share. ptyclient (a real client on a
2 //! real pty) and wsclient (the browser stand-in on a WebSocket) drive
3 //! different transports, but both read the SAME line-oriented dialect off
4 //! stdin and both report failures through the same exit codes — so the
5 //! escape table and the codes live here, in one copy. Two copies of an
6 //! escape decoder is two fixtures that can disagree about what a scenario
7 //! sent, with the scenario's heredoc reading identically either way.
8 const std = @import("std");
9
10 // Exit codes, distinct so a scenario failure names its layer:
11 // 2 usage / setup failure (bad flag, bad script line, cannot open a file)
12 // 3 an expect deadline passed
13 // 4 the far side died before the script finished — ptyclient's client on
14 // the pty, wsclient's hub on the socket
15 // Anything else a fixture exits with is the child's own status, which only
16 // ptyclient's `waitexit` propagates.
17 pub const EXIT_USAGE: u8 = 2;
18 pub const EXIT_TIMEOUT: u8 = 3;
19 pub const EXIT_DIED: u8 = 4;
20
21 /// C-style escapes: \xNN, \n, \r, \t, \\. Anything else after a backslash
22 /// is an error — a typo'd escape must fail loudly, not send mystery bytes.
23 pub fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
24 var out: std.ArrayList(u8) = .empty;
25 errdefer out.deinit(alloc);
26 var i: usize = 0;
27 while (i < s.len) : (i += 1) {
28 if (s[i] != '\\') {
29 try out.append(alloc, s[i]);
30 continue;
31 }
32 i += 1;
33 if (i >= s.len) return error.BadEscape;
34 switch (s[i]) {
35 'n' => try out.append(alloc, '\n'),
36 'r' => try out.append(alloc, '\r'),
37 't' => try out.append(alloc, '\t'),
38 '\\' => try out.append(alloc, '\\'),
39 'x' => {
40 if (i + 2 >= s.len) return error.BadEscape;
41 // Digit by digit rather than parseInt: parseInt accepts a
42 // sign, so `\x+1` would quietly decode as 0x01.
43 const hi = std.fmt.charToDigit(s[i + 1], 16) catch return error.BadEscape;
44 const lo = std.fmt.charToDigit(s[i + 2], 16) catch return error.BadEscape;
45 try out.append(alloc, hi * 16 + lo);
46 i += 2;
47 },
48 else => return error.BadEscape,
49 }
50 }
51 return out.toOwnedSlice(alloc);
52 }
53
54 test "decodeEscapes: named, hex, literal backslash" {
55 const alloc = std.testing.allocator;
56 const cases = [_]struct { in: []const u8, want: []const u8 }{
57 .{ .in = "hello\\n", .want = "hello\n" },
58 .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" },
59 .{ .in = "a\\\\b", .want = "a\\b" },
60 .{ .in = "\\x04", .want = "\x04" },
61 .{ .in = "cr\\r", .want = "cr\r" },
62 .{ .in = "tab\\there", .want = "tab\there" },
63 };
64 for (cases) |cs| {
65 const got = try decodeEscapes(alloc, cs.in);
66 defer alloc.free(got);
67 try std.testing.expectEqualSlices(u8, cs.want, got);
68 }
69 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q"));
70 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1"));
71 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trailing\\"));
72 // parseInt would take the sign and decode this as 0x01.
73 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\x+1"));
74 }
test/wsclient.zig
Old New
@@ -23,10 +23,13 @@ const std = @import("std");
23 const Engine = @import("engine").Engine; 23 const Engine = @import("engine").Engine;
24 const Replica = @import("replica").Replica; 24 const Replica = @import("replica").Replica;
25 const proto = @import("protocol"); 25 const proto = @import("protocol");
26 26 // The script dialect this fixture and ptyclient both speak: the escape
27 const EXIT_USAGE: u8 = 2; 27 // table and the exit codes (test/script.zig).
28 const EXIT_TIMEOUT: u8 = 3; 28 const script = @import("script");
29 const EXIT_DIED: u8 = 4; 29 const decodeEscapes = script.decodeEscapes;
30 const EXIT_USAGE = script.EXIT_USAGE;
31 const EXIT_TIMEOUT = script.EXIT_TIMEOUT;
32 const EXIT_DIED = script.EXIT_DIED;
30 33
31 var err_file: ?std.fs.File = null; 34 var err_file: ?std.fs.File = null;
32 35
@@ -38,37 +41,6 @@ fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn {
38 std.process.exit(code); 41 std.process.exit(code);
39 } 42 }
40 43
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 // --------------------------------------------------------------------------- 44 // ---------------------------------------------------------------------------
73 // RFC 6455, client side. The server side is std's; this is the ~60-line 45 // RFC 6455, client side. The server side is std's; this is the ~60-line
74 // mirror image: masked sends, unmasked receives. 46 // mirror image: masked sends, unmasked receives.
@@ -132,11 +104,10 @@ const WsReader = struct {
132 return .{ .opcode = opcode, .payload = b[off .. off + @as(usize, @intCast(len))], .consumed = off + @as(usize, @intCast(len)) }; 104 return .{ .opcode = opcode, .payload = b[off .. off + @as(usize, @intCast(len))], .consumed = off + @as(usize, @intCast(len)) };
133 } 105 }
134 106
135 fn consume(self: *WsReader, alloc: std.mem.Allocator, n: usize) void { 107 fn consume(self: *WsReader, n: usize) void {
136 const rest = self.buf.items[n..]; 108 const rest = self.buf.items[n..];
137 std.mem.copyForwards(u8, self.buf.items[0..rest.len], rest); 109 std.mem.copyForwards(u8, self.buf.items[0..rest.len], rest);
138 self.buf.shrinkRetainingCapacity(rest.len); 110 self.buf.shrinkRetainingCapacity(rest.len);
139 _ = alloc;
140 } 111 }
141 }; 112 };
142 113
@@ -146,11 +117,14 @@ const Client = struct {
146 alloc: std.mem.Allocator, 117 alloc: std.mem.Allocator,
147 sock: std.posix.fd_t, 118 sock: std.posix.fd_t,
148 reader: WsReader = .{}, 119 reader: WsReader = .{},
149 eng: *Engine,
150 rep: Replica, 120 rep: Replica,
151 /// Last control-message state seen (the tile chrome's vocabulary). 121 /// Last control-message state seen (the tile chrome's vocabulary).
152 last_state: [16]u8 = @splat(0), 122 last_state: [16]u8 = @splat(0),
153 last_state_len: usize = 0, 123 last_state_len: usize = 0,
124 /// The size the last script-level `attach` quoted. Every attach this
125 /// fixture ever sends quotes THIS, never the grid — see sendAttach.
126 att_cols: u16 = 0,
127 att_rows: u16 = 0,
154 128
155 fn sendMessage(self: *Client, payload: []const u8) void { 129 fn sendMessage(self: *Client, payload: []const u8) void {
156 self.sendRaw(0x82, payload); // FIN | binary 130 self.sendRaw(0x82, payload); // FIN | binary
@@ -176,6 +150,27 @@ const Client = struct {
176 self.sendMessage(out.items); 150 self.sendMessage(out.items);
177 } 151 }
178 152
153 /// The ONE place an attach frame is built, which is what keeps the
154 /// passivity contract honest. A wall tile attaches at its own size —
155 /// 1x1, refused the grid by applySize and refused claimGrid forever
156 /// after — and every LATER attach has to quote that same size. The
157 /// resync path is the trap: re-attaching at the grid the snapshot
158 /// taught us (80x24) is an attach at a differing size, which moves the
159 /// shared grid and repaints every other client, so a stand-in that did
160 /// that would be pinning the opposite of the contract. The browser has
161 /// the same rule structurally: mux.js routes every attach through
162 /// sendAttach, which quotes the tile's scripted size.
163 ///
164 /// `fresh` quotes (0,0) instead of the replica's resume coordinates —
165 /// what a resync needs, since there the replica is the suspect part.
166 fn sendAttach(self: *Client, cols: u16, rows: u16, fresh: bool) void {
167 self.att_cols = cols;
168 self.att_rows = rows;
169 const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.rep.attachArgs();
170 const att = proto.encodeAttach(cols, rows, q.have_seq, q.have_epoch);
171 self.sendFrame(@intFromEnum(proto.MsgType.attach), &att);
172 }
173
179 /// Pump whatever is on the socket into the reader and apply every 174 /// Pump whatever is on the socket into the reader and apply every
180 /// whole message. Returns false when the hub hung up. `wait_ms` is 175 /// whole message. Returns false when the hub hung up. `wait_ms` is
181 /// one poll's patience, not a deadline. 176 /// one poll's patience, not a deadline.
@@ -192,7 +187,7 @@ const Client = struct {
192 } 187 }
193 while (self.reader.peek()) |msg| { 188 while (self.reader.peek()) |msg| {
194 self.handle(msg); 189 self.handle(msg);
195 self.reader.consume(self.alloc, msg.consumed); 190 self.reader.consume(msg.consumed);
196 } 191 }
197 return true; 192 return true;
198 } 193 }
@@ -240,9 +235,10 @@ const Client = struct {
240 if (t == snapshot or t == delta) { 235 if (t == snapshot or t == delta) {
241 const applied = self.rep.apply(@enumFromInt(t), payload) catch return; 236 const applied = self.rep.apply(@enumFromInt(t), payload) catch return;
242 if (applied == .resync) { 237 if (applied == .resync) {
243 // Mirror the browser: a garbled delta re-attaches fresh. 238 // Mirror the browser: a garbled delta re-attaches fresh,
244 var att = proto.encodeAttach(self.rep.grid.cols, self.rep.grid.rows, 0, 0); 239 // at the TILE's size — never at rep.grid, which is the
245 self.sendFrame(@intFromEnum(proto.MsgType.attach), &att); 240 // authoritative grid this tile is not allowed to move.
241 self.sendAttach(self.att_cols, self.att_rows, true);
246 } 242 }
247 } 243 }
248 // Everything else (exit_status, pty_mode, scrollback) is visible 244 // Everything else (exit_status, pty_mode, scrollback) is visible
@@ -282,23 +278,35 @@ pub fn main() !void {
282 var i: usize = 1; 278 var i: usize = 1;
283 while (i < args.len) : (i += 1) { 279 while (i < args.len) : (i += 1) {
284 const a = args[i]; 280 const a = args[i];
285 if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { 281 // The value check is INSIDE each arm rather than in its condition:
282 // with `and i + 1 < args.len` up there, a flag in final position
283 // falls through to the else and reports "unknown arg --port",
284 // sending the operator to look for a typo in a flag that is
285 // spelled correctly. ptyclient's loop is the model.
286 if (std.mem.eql(u8, a, "--port")) {
286 i += 1; 287 i += 1;
287 port = std.fmt.parseInt(u16, args[i], 10) catch fatal(EXIT_USAGE, "bad --port", .{}); 288 if (i >= args.len) fatal(EXIT_USAGE, "--port needs a value", .{});
288 } else if (std.mem.eql(u8, a, "--tile") and i + 1 < args.len) { 289 port = std.fmt.parseInt(u16, args[i], 10) catch
290 fatal(EXIT_USAGE, "--port: not a number: {s}", .{args[i]});
291 } else if (std.mem.eql(u8, a, "--tile")) {
289 i += 1; 292 i += 1;
290 tile = std.fmt.parseInt(usize, args[i], 10) catch fatal(EXIT_USAGE, "bad --tile", .{}); 293 if (i >= args.len) fatal(EXIT_USAGE, "--tile needs a value", .{});
291 } else if (std.mem.eql(u8, a, "--out") and i + 1 < args.len) { 294 tile = std.fmt.parseInt(usize, args[i], 10) catch
295 fatal(EXIT_USAGE, "--tile: not a number: {s}", .{args[i]});
296 } else if (std.mem.eql(u8, a, "--out")) {
292 i += 1; 297 i += 1;
298 if (i >= args.len) fatal(EXIT_USAGE, "--out needs a path", .{});
293 out_path = args[i]; 299 out_path = args[i];
294 } else if (std.mem.eql(u8, a, "--err") and i + 1 < args.len) { 300 } else if (std.mem.eql(u8, a, "--err")) {
295 i += 1; 301 i += 1;
302 if (i >= args.len) fatal(EXIT_USAGE, "--err needs a path", .{});
296 err_path = args[i]; 303 err_path = args[i];
297 } else if (std.mem.eql(u8, a, "--origin") and i + 1 < args.len) { 304 } else if (std.mem.eql(u8, a, "--origin")) {
298 i += 1; 305 i += 1;
306 if (i >= args.len) fatal(EXIT_USAGE, "--origin needs a value", .{});
299 origin = args[i]; 307 origin = args[i];
300 } else { 308 } else {
301 fatal(EXIT_USAGE, "unknown arg {s}", .{a}); 309 fatal(EXIT_USAGE, "unknown arg {s} (usage: wsclient --port N --tile IDX --out F --err F [--origin STR])", .{a});
302 } 310 }
303 } 311 }
304 const p = port orelse fatal(EXIT_USAGE, "--port required", .{}); 312 const p = port orelse fatal(EXIT_USAGE, "--port required", .{});
@@ -360,7 +368,7 @@ pub fn main() !void {
360 // --- replica + client --- 368 // --- replica + client ---
361 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 369 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
362 defer eng.deinit(); 370 defer eng.deinit();
363 var cl = Client{ .alloc = alloc, .sock = sock, .eng = eng, .rep = Replica.init(alloc, eng) }; 371 var cl = Client{ .alloc = alloc, .sock = sock, .rep = Replica.init(alloc, eng) };
364 defer cl.reader.buf.deinit(alloc); 372 defer cl.reader.buf.deinit(alloc);
365 // Bytes past the head are the first WS frames. 373 // Bytes past the head are the first WS frames.
366 try cl.reader.buf.appendSlice(alloc, head.items[head_end..]); 374 try cl.reader.buf.appendSlice(alloc, head.items[head_end..]);
@@ -397,10 +405,11 @@ pub fn main() !void {
397 var it = std.mem.tokenizeScalar(u8, rest, ' '); 405 var it = std.mem.tokenizeScalar(u8, rest, ' ');
398 const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{}); 406 const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{});
399 const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{}); 407 const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{});
400 const fresh = std.mem.eql(u8, verb, "attachfresh"); 408 // Exactly two, like ptyclient's resize: a third token means the
401 const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else cl.rep.attachArgs(); 409 // operator meant something this verb does not do, and silently
402 const att = proto.encodeAttach(cols, rows, q.have_seq, q.have_epoch); 410 // dropping it is how a scenario ends up asserting nothing.
403 cl.sendFrame(@intFromEnum(proto.MsgType.attach), &att); 411 if (it.next() != null) fatal(EXIT_USAGE, "attach C R takes exactly two arguments", .{});
412 cl.sendAttach(cols, rows, std.mem.eql(u8, verb, "attachfresh"));
404 } else if (std.mem.eql(u8, verb, "send")) { 413 } else if (std.mem.eql(u8, verb, "send")) {
405 const bytes = decodeEscapes(alloc, rest) catch fatal(EXIT_USAGE, "bad escape in send", .{}); 414 const bytes = decodeEscapes(alloc, rest) catch fatal(EXIT_USAGE, "bad escape in send", .{});
406 defer alloc.free(bytes); 415 defer alloc.free(bytes);
@@ -409,6 +418,7 @@ pub fn main() !void {
409 var it = std.mem.tokenizeScalar(u8, rest, ' '); 418 var it = std.mem.tokenizeScalar(u8, rest, ' ');
410 const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "resize C R", .{}), 10) catch fatal(EXIT_USAGE, "resize C R", .{}); 419 const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "resize C R", .{}), 10) catch fatal(EXIT_USAGE, "resize C R", .{});
411 const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "resize C R", .{}), 10) catch fatal(EXIT_USAGE, "resize C R", .{}); 420 const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "resize C R", .{}), 10) catch fatal(EXIT_USAGE, "resize C R", .{});
421 if (it.next() != null) fatal(EXIT_USAGE, "resize C R takes exactly two arguments", .{});
412 const sz = proto.encodeSize(cols, rows); 422 const sz = proto.encodeSize(cols, rows);
413 cl.sendFrame(@intFromEnum(proto.MsgType.resize), &sz); 423 cl.sendFrame(@intFromEnum(proto.MsgType.resize), &sz);
414 } else if (std.mem.eql(u8, verb, "expectgrid")) { 424 } else if (std.mem.eql(u8, verb, "expectgrid")) {
@@ -428,6 +438,11 @@ pub fn main() !void {
428 } 438 }
429 } else if (std.mem.eql(u8, verb, "expectstate")) { 439 } else if (std.mem.eql(u8, verb, "expectstate")) {
430 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse fatal(EXIT_USAGE, "expectstate STATE MS", .{}); 440 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse fatal(EXIT_USAGE, "expectstate STATE MS", .{});
441 // A doubled space leaves an empty state, and the empty string
442 // is what last_state holds before any control message — so the
443 // wait would pass on having observed nothing. expectgrid
444 // refuses the same shape above.
445 if (last == 0) fatal(EXIT_USAGE, "empty state", .{});
431 const ms = std.fmt.parseInt(i64, rest[last + 1 ..], 10) catch fatal(EXIT_USAGE, "bad deadline", .{}); 446 const ms = std.fmt.parseInt(i64, rest[last + 1 ..], 10) catch fatal(EXIT_USAGE, "bad deadline", .{});
432 const want = rest[0..last]; 447 const want = rest[0..last];
433 const deadline = nowMs() + ms; 448 const deadline = nowMs() + ms;
@@ -442,7 +457,13 @@ pub fn main() !void {
442 const deadline = nowMs() + ms; 457 const deadline = nowMs() + ms;
443 var last_traffic = nowMs(); 458 var last_traffic = nowMs();
444 while (nowMs() - last_traffic < quiet) { 459 while (nowMs() - last_traffic < quiet) {
445 if (nowMs() >= deadline) break; 460 // Not `break`: a settle that gives up silently reports a
461 // quiesced session it never observed, and the scenario
462 // that follows then diffs a grid still in motion — a
463 // divergence blamed on the replica. ptyclient's settle
464 // exits 3 here for the same reason.
465 if (nowMs() >= deadline)
466 fatal(EXIT_TIMEOUT, "settle: never saw {d}ms of silence within {d}ms (state '{s}', seq {d})", .{ quiet, ms, cl.last_state[0..cl.last_state_len], cl.rep.last_seq });
446 const before = cl.rep.last_seq; 467 const before = cl.rep.last_seq;
447 if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during settle", .{}); 468 if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during settle", .{});
448 if (cl.rep.last_seq != before) last_traffic = nowMs(); 469 if (cl.rep.last_seq != before) last_traffic = nowMs();
@@ -524,10 +545,94 @@ test "ws reader: split delivery reassembles; server frames arrive unmasked" {
524 const msg = r.peek().?; 545 const msg = r.peek().?;
525 try std.testing.expectEqual(@as(u4, 2), msg.opcode); 546 try std.testing.expectEqual(@as(u4, 2), msg.opcode);
526 try std.testing.expectEqualSlices(u8, &payload, msg.payload); 547 try std.testing.expectEqualSlices(u8, &payload, msg.payload);
527 r.consume(alloc, msg.consumed); 548 r.consume(msg.consumed);
528 try std.testing.expectEqual(@as(usize, 0), r.buf.items.len); 549 try std.testing.expectEqual(@as(usize, 0), r.buf.items.len);
529 } 550 }
530 551
552 test "the resync re-attach quotes the TILE's size, never the grid it learned" {
553 // The passivity contract, pinned at the WS WRITE SEAM: a wall tile
554 // attaches at 1x1, learns the authoritative 80x24 grid from the
555 // snapshot, and then hits a garbled delta. The re-attach that follows
556 // must still say 1x1 — quoting the learned grid would be an attach at
557 // a differing size, which claims the shared session and repaints every
558 // other client, i.e. the exact opposite of the contract scenario (a)
559 // exists to hold.
560 const alloc = std.testing.allocator;
561 const fds = try std.posix.pipe();
562 defer std.posix.close(fds[0]);
563 defer std.posix.close(fds[1]);
564
565 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
566 defer eng.deinit();
567 var cl = Client{ .alloc = alloc, .sock = fds[1], .rep = Replica.init(alloc, eng) };
568 defer cl.reader.buf.deinit(alloc);
569
570 cl.sendAttach(1, 1, false);
571
572 // The daemon's answer: a unicast snapshot carrying the true grid.
573 var snap: std.ArrayList(u8) = .empty;
574 defer snap.deinit(alloc);
575 try snap.appendSlice(alloc, &[_]u8{ 0x00, @intFromEnum(proto.MsgType.snapshot), 0, 0, 0, 0 });
576 const state = try eng.dumpState(alloc);
577 defer alloc.free(state);
578 const snap_payload_len = proto.snapshot_prefix_len + state.len;
579 try snap.appendNTimes(alloc, 0, proto.snapshot_prefix_len);
580 proto.writeSnapshotPrefix(snap.items[6..][0..proto.snapshot_prefix_len], .{
581 .seq = 7,
582 .history_rows = 0,
583 .cols = 80,
584 .rows = 24,
585 .epoch = 3,
586 });
587 try snap.appendSlice(alloc, state);
588 std.mem.writeInt(u32, snap.items[2..6], @intCast(snap_payload_len), .little);
589 cl.handle(.{ .opcode = 0x2, .payload = snap.items, .consumed = snap.items.len });
590 try std.testing.expectEqual(@as(u16, 80), cl.rep.grid.cols);
591
592 // A delta whose header claims two rows and whose payload carries one:
593 // composeDelta rejects it and Replica reports .resync.
594 var body: std.ArrayList(u8) = .empty;
595 defer body.deinit(alloc);
596 try proto.appendDeltaHeader(&body, alloc, .{
597 .seq = 8,
598 .history_rows = 0,
599 .cursor_x = 0,
600 .cursor_y = 0,
601 .row_count = 2,
602 });
603 try proto.appendDeltaRow(&body, alloc, 0, "x");
604 var bad: std.ArrayList(u8) = .empty;
605 defer bad.deinit(alloc);
606 try bad.appendSlice(alloc, &[_]u8{ 0x00, @intFromEnum(proto.MsgType.delta), 0, 0, 0, 0 });
607 std.mem.writeInt(u32, bad.items[2..6], @intCast(body.items.len), .little);
608 try bad.appendSlice(alloc, body.items);
609 cl.handle(.{ .opcode = 0x2, .payload = bad.items, .consumed = bad.items.len });
610
611 // Both attaches, off the wire, unmasked the way the hub would.
612 var got: [1024]u8 = undefined;
613 const n = try std.posix.read(fds[0], &got);
614 var off: usize = 0;
615 var attaches: usize = 0;
616 var last: proto.AttachReq = undefined;
617 while (off + 6 <= n) {
618 const plen: usize = got[off + 1] & 0x7f;
619 const mask = got[off + 2 ..][0..4].*;
620 var msg: [64]u8 = undefined;
621 for (got[off + 6 ..][0..plen], 0..) |c, k| msg[k] = c ^ mask[k % 4];
622 if (msg[1] == @intFromEnum(proto.MsgType.attach)) {
623 last = try proto.decodeAttach(msg[6..plen]);
624 attaches += 1;
625 }
626 off += 6 + plen;
627 }
628 try std.testing.expectEqual(@as(usize, 2), attaches);
629 try std.testing.expectEqual(@as(u16, 1), last.cols);
630 try std.testing.expectEqual(@as(u16, 1), last.rows);
631 // ...and fresh: what we hold is what was garbled.
632 try std.testing.expectEqual(@as(u64, 0), last.have_seq);
633 try std.testing.expectEqual(@as(u64, 0), last.have_epoch);
634 }
635
531 test "the dump this exits with is the daemon's own dump format" { 636 test "the dump this exits with is the daemon's own dump format" {
532 // dumpexit writes Engine.dumpPlain — the SAME function muxd dump 637 // dumpexit writes Engine.dumpPlain — the SAME function muxd dump
533 // prints through — so the e2e diff cannot fail on formatting. The 638 // prints through — so the e2e diff cannot fail on formatting. The