b03d9834
feat: ptyclient script engine — escapes, consume-cursor expect, verb parse
a73x 2026-08-10 07:21
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -222,6 +222,17 @@ pub fn build(b: *std.Build) void { | |||
| 222 | }); | 222 | }); |
| 223 | render_mod.addImport("engine", engine_mod); | 223 | render_mod.addImport("engine", engine_mod); |
| 224 | 224 | ||
| 225 | // The pty-driving e2e fixture: real client on a pty slave, scripted | ||
| 226 | // from stdin (M12). Imports pty so the product's own module is the one | ||
| 227 | // under it. | ||
| 228 | const ptyclient_mod = b.createModule(.{ | ||
| 229 | .root_source_file = b.path("test/ptyclient.zig"), | ||
| 230 | .target = target, | ||
| 231 | .optimize = optimize, | ||
| 232 | .link_libc = true, | ||
| 233 | }); | ||
| 234 | ptyclient_mod.addImport("pty", pty_mod); | ||
| 235 | |||
| 225 | const exe_mod = b.createModule(.{ | 236 | const exe_mod = b.createModule(.{ |
| 226 | .root_source_file = b.path("src/main.zig"), | 237 | .root_source_file = b.path("src/main.zig"), |
| 227 | .target = target, | 238 | .target = target, |
| @@ -273,13 +284,18 @@ pub fn build(b: *std.Build) void { | |||
| 273 | render_exe.use_lld = true; | 284 | render_exe.use_lld = true; |
| 274 | b.installArtifact(render_exe); | 285 | b.installArtifact(render_exe); |
| 275 | 286 | ||
| 287 | const ptyclient_exe = b.addExecutable(.{ .name = "ptyclient", .root_module = ptyclient_mod }); | ||
| 288 | ptyclient_exe.use_llvm = true; | ||
| 289 | ptyclient_exe.use_lld = true; | ||
| 290 | b.installArtifact(ptyclient_exe); | ||
| 291 | |||
| 276 | const test_step = b.step("test", "Run unit tests"); | 292 | const test_step = b.step("test", "Run unit tests"); |
| 277 | // mux_mod and exe_mod are executable roots, but they carry the argument | 293 | // mux_mod and exe_mod are executable roots, but they carry the argument |
| 278 | // parsers, and a test that is never built is not a test. exe_mod's | 294 | // parsers, and a test that is never built is not a test. exe_mod's |
| 279 | // absence here was a live hazard recorded in decisions.md — muxd's | 295 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 280 | // entrypoint could grow tests that silently never ran, exactly as | 296 | // entrypoint could grow tests that silently never ran, exactly as |
| 281 | // mux_main.zig's five did before it was added. | 297 | // mux_main.zig's five did before it was added. |
| 282 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, render_mod }) |mod| { | 298 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, render_mod, ptyclient_mod }) |mod| { |
| 283 | const t = b.addTest(.{ .root_module = mod }); | 299 | const t = b.addTest(.{ .root_module = mod }); |
| 284 | t.use_llvm = true; | 300 | t.use_llvm = true; |
| 285 | t.use_lld = true; | 301 | t.use_lld = true; |
| @@ -303,6 +319,7 @@ pub fn build(b: *std.Build) void { | |||
| 303 | e2e.addArtifactArg(rawmode_exe); | 319 | e2e.addArtifactArg(rawmode_exe); |
| 304 | e2e.addArtifactArg(delaypipe_exe); | 320 | e2e.addArtifactArg(delaypipe_exe); |
| 305 | e2e.addArtifactArg(render_exe); | 321 | e2e.addArtifactArg(render_exe); |
| 322 | e2e.addArtifactArg(ptyclient_exe); | ||
| 306 | const e2e_step = b.step("e2e", "Run end-to-end test"); | 323 | const e2e_step = b.step("e2e", "Run end-to-end test"); |
| 307 | e2e_step.dependOn(&e2e.step); | 324 | e2e_step.dependOn(&e2e.step); |
| 308 | 325 | ||
| @@ -312,6 +329,7 @@ pub fn build(b: *std.Build) void { | |||
| 312 | soak.addArtifactArg(rawmode_exe); | 329 | soak.addArtifactArg(rawmode_exe); |
| 313 | soak.addArtifactArg(delaypipe_exe); | 330 | soak.addArtifactArg(delaypipe_exe); |
| 314 | soak.addArtifactArg(render_exe); | 331 | soak.addArtifactArg(render_exe); |
| 332 | soak.addArtifactArg(ptyclient_exe); | ||
| 315 | const soak_step = b.step("soak", "Run the e2e suite SOAK_N times (default 10)"); | 333 | const soak_step = b.step("soak", "Run the e2e suite SOAK_N times (default 10)"); |
| 316 | soak_step.dependOn(&soak.step); | 334 | soak_step.dependOn(&soak.step); |
| 317 | 335 | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,8 @@ RAWMODE="$3" | |||
| 10 | DELAYPIPE="$4" | 10 | DELAYPIPE="$4" |
| 11 | # M11 convergence: replays a client capture into a grid (test/render.zig). | 11 | # M11 convergence: replays a client capture into a grid (test/render.zig). |
| 12 | RENDER="$5" | 12 | RENDER="$5" |
| 13 | # M12 pty fixture: runs the client on a real pty (test/ptyclient.zig). | ||
| 14 | PTYCLIENT="$6" | ||
| 13 | SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" | 15 | SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" |
| 14 | OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" | 16 | OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" |
| 15 | # M10: hermetic XDG homes. Key-default scenarios must see OUR key or none, | 17 | # M10: hermetic XDG homes. Key-default scenarios must see OUR key or none, |
test/ptyclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,173 @@ | |||
| 1 | //! e2e fixture: runs the real mux client on the slave side of a pty it | ||
| 2 | //! owns, so `isatty()` answers yes and the tty-gated branches open. Driven | ||
| 3 | //! by a line-oriented script on stdin; everything read from the master | ||
| 4 | //! tees into --out so the convergence machinery consumes the same capture | ||
| 5 | //! files non-tty scenarios produce. Spec: docs/superpowers/specs/ | ||
| 6 | //! 2026-08-10-m12-ptyclient-design.md. | ||
| 7 | const std = @import("std"); | ||
| 8 | const Pty = @import("pty").Pty; | ||
| 9 | |||
| 10 | /// C-style escapes: \xNN, \n, \r, \t, \\. Anything else after a backslash | ||
| 11 | /// is an error — a typo'd escape must fail loudly, not send mystery bytes. | ||
| 12 | fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 { | ||
| 13 | var out: std.ArrayList(u8) = .empty; | ||
| 14 | errdefer out.deinit(alloc); | ||
| 15 | var i: usize = 0; | ||
| 16 | while (i < s.len) : (i += 1) { | ||
| 17 | if (s[i] != '\\') { | ||
| 18 | try out.append(alloc, s[i]); | ||
| 19 | continue; | ||
| 20 | } | ||
| 21 | i += 1; | ||
| 22 | if (i >= s.len) return error.BadEscape; | ||
| 23 | switch (s[i]) { | ||
| 24 | 'n' => try out.append(alloc, '\n'), | ||
| 25 | 'r' => try out.append(alloc, '\r'), | ||
| 26 | 't' => try out.append(alloc, '\t'), | ||
| 27 | '\\' => try out.append(alloc, '\\'), | ||
| 28 | 'x' => { | ||
| 29 | if (i + 2 >= s.len) return error.BadEscape; | ||
| 30 | const b = std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16) catch | ||
| 31 | return error.BadEscape; | ||
| 32 | try out.append(alloc, b); | ||
| 33 | i += 2; | ||
| 34 | }, | ||
| 35 | else => return error.BadEscape, | ||
| 36 | } | ||
| 37 | } | ||
| 38 | return out.toOwnedSlice(alloc); | ||
| 39 | } | ||
| 40 | |||
| 41 | /// Accumulates everything read off the master and matches needles with | ||
| 42 | /// expect(1) semantics: the search starts at a cursor, and a match | ||
| 43 | /// advances the cursor past itself. Without the cursor, a needle painted | ||
| 44 | /// BEFORE the previous verb would satisfy this one — tp1's post-scroll | ||
| 45 | /// expect would pass on bytes from the initial snapshot. | ||
| 46 | const Expecter = struct { | ||
| 47 | buf: std.ArrayList(u8) = .empty, | ||
| 48 | cursor: usize = 0, | ||
| 49 | |||
| 50 | fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void { | ||
| 51 | try self.buf.appendSlice(alloc, bytes); | ||
| 52 | } | ||
| 53 | |||
| 54 | fn match(self: *Expecter, needle: []const u8) bool { | ||
| 55 | if (std.mem.indexOfPos(u8, self.buf.items, self.cursor, needle)) |i| { | ||
| 56 | self.cursor = i + needle.len; | ||
| 57 | return true; | ||
| 58 | } | ||
| 59 | return false; | ||
| 60 | } | ||
| 61 | |||
| 62 | fn deinit(self: *Expecter, alloc: std.mem.Allocator) void { | ||
| 63 | self.buf.deinit(alloc); | ||
| 64 | } | ||
| 65 | }; | ||
| 66 | |||
| 67 | const Verb = union(enum) { | ||
| 68 | send: []u8, | ||
| 69 | expect: struct { needle: []u8, deadline_ms: u64 }, | ||
| 70 | resize: struct { cols: u16, rows: u16 }, | ||
| 71 | waitexit: u64, | ||
| 72 | }; | ||
| 73 | |||
| 74 | /// One script line -> one verb; blank lines and #-comments are null. | ||
| 75 | /// Payloads may contain spaces: `send` takes the whole rest of the line; | ||
| 76 | /// `expect` takes everything up to the LAST space, then the deadline. | ||
| 77 | fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb { | ||
| 78 | const line = std.mem.trim(u8, raw, " \t\r"); | ||
| 79 | if (line.len == 0 or line[0] == '#') return null; | ||
| 80 | const sp = std.mem.indexOfScalar(u8, line, ' ') orelse return error.BadVerb; | ||
| 81 | const verb = line[0..sp]; | ||
| 82 | const rest = line[sp + 1 ..]; | ||
| 83 | if (std.mem.eql(u8, verb, "send")) { | ||
| 84 | return .{ .send = try decodeEscapes(alloc, rest) }; | ||
| 85 | } else if (std.mem.eql(u8, verb, "expect")) { | ||
| 86 | const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb; | ||
| 87 | const ms = std.fmt.parseInt(u64, rest[last + 1 ..], 10) catch return error.BadVerb; | ||
| 88 | return .{ .expect = .{ | ||
| 89 | .needle = try decodeEscapes(alloc, rest[0..last]), | ||
| 90 | .deadline_ms = ms, | ||
| 91 | } }; | ||
| 92 | } else if (std.mem.eql(u8, verb, "resize")) { | ||
| 93 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 94 | const cols = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 95 | const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; | ||
| 96 | if (it.next() != null) return error.BadVerb; | ||
| 97 | return .{ .resize = .{ .cols = cols, .rows = rows } }; | ||
| 98 | } else if (std.mem.eql(u8, verb, "waitexit")) { | ||
| 99 | const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; | ||
| 100 | return .{ .waitexit = ms }; | ||
| 101 | } | ||
| 102 | return error.BadVerb; | ||
| 103 | } | ||
| 104 | |||
| 105 | /// Task 3 replaces this: argument parsing, pty spawn, and the verb loop. | ||
| 106 | pub fn main() !void { | ||
| 107 | std.debug.print("ptyclient: script engine only — the verb loop lands with Task 3\n", .{}); | ||
| 108 | std.process.exit(2); | ||
| 109 | } | ||
| 110 | |||
| 111 | test "decodeEscapes: named, hex, literal backslash" { | ||
| 112 | const alloc = std.testing.allocator; | ||
| 113 | const cases = [_]struct { in: []const u8, want: []const u8 }{ | ||
| 114 | .{ .in = "hello\\n", .want = "hello\n" }, | ||
| 115 | .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" }, | ||
| 116 | .{ .in = "a\\\\b", .want = "a\\b" }, | ||
| 117 | .{ .in = "\\x04", .want = "\x04" }, | ||
| 118 | }; | ||
| 119 | for (cases) |cs| { | ||
| 120 | const got = try decodeEscapes(alloc, cs.in); | ||
| 121 | defer alloc.free(got); | ||
| 122 | try std.testing.expectEqualSlices(u8, cs.want, got); | ||
| 123 | } | ||
| 124 | try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q")); | ||
| 125 | try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1")); | ||
| 126 | } | ||
| 127 | |||
| 128 | test "Expecter: a needle split across two feeds still matches" { | ||
| 129 | const alloc = std.testing.allocator; | ||
| 130 | var e: Expecter = .{}; | ||
| 131 | defer e.deinit(alloc); | ||
| 132 | try e.feed(alloc, "scroll-mar"); | ||
| 133 | try std.testing.expect(!e.match("marker")); | ||
| 134 | try e.feed(alloc, "ker arrived"); | ||
| 135 | try std.testing.expect(e.match("marker")); | ||
| 136 | } | ||
| 137 | |||
| 138 | test "Expecter: the cursor consumes matches — old bytes cannot satisfy a new expect" { | ||
| 139 | const alloc = std.testing.allocator; | ||
| 140 | var e: Expecter = .{}; | ||
| 141 | defer e.deinit(alloc); | ||
| 142 | try e.feed(alloc, "row-60 painted live"); | ||
| 143 | try std.testing.expect(e.match("row-60")); | ||
| 144 | // The same needle again: only NEW bytes may answer. | ||
| 145 | try std.testing.expect(!e.match("row-60")); | ||
| 146 | try e.feed(alloc, " ... row-60 painted by the scroll view"); | ||
| 147 | try std.testing.expect(e.match("row-60")); | ||
| 148 | } | ||
| 149 | |||
| 150 | test "parseLine: verbs, spaces in payloads, comments" { | ||
| 151 | const alloc = std.testing.allocator; | ||
| 152 | try std.testing.expect(try parseLine(alloc, "") == null); | ||
| 153 | try std.testing.expect(try parseLine(alloc, "# comment") == null); | ||
| 154 | |||
| 155 | const s = (try parseLine(alloc, "send echo tp2-claim\\n")).?; | ||
| 156 | defer alloc.free(s.send); | ||
| 157 | try std.testing.expectEqualSlices(u8, "echo tp2-claim\n", s.send); | ||
| 158 | |||
| 159 | const x = (try parseLine(alloc, "expect two words 15000")).?; | ||
| 160 | defer alloc.free(x.expect.needle); | ||
| 161 | try std.testing.expectEqualSlices(u8, "two words", x.expect.needle); | ||
| 162 | try std.testing.expectEqual(@as(u64, 15000), x.expect.deadline_ms); | ||
| 163 | |||
| 164 | const r = (try parseLine(alloc, "resize 90 28")).?; | ||
| 165 | try std.testing.expectEqual(@as(u16, 90), r.resize.cols); | ||
| 166 | try std.testing.expectEqual(@as(u16, 28), r.resize.rows); | ||
| 167 | |||
| 168 | const w = (try parseLine(alloc, "waitexit 10000")).?; | ||
| 169 | try std.testing.expectEqual(@as(u64, 10000), w.waitexit); | ||
| 170 | |||
| 171 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x")); | ||
| 172 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline")); | ||
| 173 | } | ||
test/soak.sh
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,7 @@ | |||
| 6 | # same temp-file patterns while it looks (a concurrently running suite | 6 | # same temp-file patterns while it looks (a concurrently running suite |
| 7 | # would read as a leak). | 7 | # would read as a leak). |
| 8 | set -u | 8 | set -u |
| 9 | MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5" | 9 | MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5"; PTYCLIENT="$6" |
| 10 | E2E="$(dirname "$0")/e2e.sh" | 10 | E2E="$(dirname "$0")/e2e.sh" |
| 11 | N="${SOAK_N:-10}" | 11 | N="${SOAK_N:-10}" |
| 12 | TMP="${TMPDIR:-/tmp}" | 12 | TMP="${TMPDIR:-/tmp}" |
| @@ -30,7 +30,7 @@ BASE_STRAYS=$(find "$TMP" -maxdepth 1 \( -name 'muxd-e2e-*' -o -name 'mux-e2e-*' | |||
| 30 | FAILED=0 | 30 | FAILED=0 |
| 31 | i=1 | 31 | i=1 |
| 32 | while [ "$i" -le "$N" ]; do | 32 | while [ "$i" -le "$N" ]; do |
| 33 | if "$E2E" "$MUXD" "$MUX" "$RAWMODE" "$DELAYPIPE" "$RENDER" > "$LOG" 2>&1; then | 33 | if "$E2E" "$MUXD" "$MUX" "$RAWMODE" "$DELAYPIPE" "$RENDER" "$PTYCLIENT" > "$LOG" 2>&1; then |
| 34 | echo "soak run $i/$N: PASS" | 34 | echo "soak run $i/$N: PASS" |
| 35 | else | 35 | else |
| 36 | FAILED=$((FAILED + 1)) | 36 | FAILED=$((FAILED + 1)) |