123bf352
fix(test): the wall tile's stand-in stays 1x1 across a resync
a73x 2026-08-13 14:46
Commit message
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 |