a73x

b03d9834

feat: ptyclient script engine — escapes, consume-cursor expect, verb parse

a73x   2026-08-10 07:21

Commit message
feat: ptyclient script engine — escapes, consume-cursor expect, verb parse

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))