a73x

00d2f8fb

feat: ptyclient main loop + fixture controls in the suite

a73x   2026-08-10 07:52

Commit message
feat: ptyclient main loop + fixture controls in the suite

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

test/e2e.sh
Old New
@@ -1507,12 +1507,47 @@ assert_converged "$OUT.pr" "$SOCK5" "reconnect flush"
1507 kill "$D5PID" 2>/dev/null || true 1507 kill "$D5PID" 2>/dev/null || true
1508 D5PID="" 1508 D5PID=""
1509 1509
1510 # ---- M12: ptyclient fixture controls ----------------------------------
1511 # Before any scenario trusts the fixture, prove both directions: a
1512 # roundtrip over plain /bin/cat (the pty line discipline's own echo
1513 # answers — no mux anywhere, so a failure here is the FIXTURE'S), and an
1514 # expect that cannot match, which must time out, exit nonzero, and say
1515 # what it did see. A check that cannot fail proves nothing.
1516 PCLOG="$OUT.pc.log"
1517 set +e
1518 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc" --err "$OUT.pc.err" -- /bin/cat > "$PCLOG" 2>&1 <<'EOF'
1519 send hello\n
1520 expect hello 10000
1521 send \x04
1522 waitexit 10000
1523 EOF
1524 RC=$?
1525 set -e
1526 [ "$RC" -eq 0 ] || {
1527 echo "e2e FAIL: ptyclient roundtrip over cat exited $RC:"; cat "$PCLOG"; exit 1; }
1528 grep -q "hello" "$OUT.pc" || {
1529 echo "e2e FAIL: ptyclient capture missing the pty echo"; cat -v "$OUT.pc"; exit 1; }
1530 # The must-fail leg. 500ms: nothing is being waited FOR — the needle never
1531 # arrives by construction — so the deadline only bounds the control's cost.
1532 set +e
1533 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc2" --err "$OUT.pc2.err" -- /bin/cat > "$PCLOG.2" 2>&1 <<'EOF'
1534 expect never-going-to-match 500
1535 EOF
1536 RC=$?
1537 set -e
1538 [ "$RC" -ne 0 ] || {
1539 echo "e2e FAIL: ptyclient expect control did not fire on an impossible needle"; exit 1; }
1540 grep -q "did not arrive" "$PCLOG.2" || {
1541 echo "e2e FAIL: ptyclient timeout fired but never said what it saw"; cat "$PCLOG.2"; exit 1; }
1542 rm -f "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$PCLOG" "$PCLOG.2"
1543 ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly"
1544
1510 # The pins. Literals, not variables set from counting something else — 1545 # The pins. Literals, not variables set from counting something else —
1511 # "assert the literal, never the constant the code under test reads" 1546 # "assert the literal, never the constant the code under test reads"
1512 # (decisions.md, M10). 10 scenario checkpoints; 22 convergence points. 1547 # (decisions.md, M10). 11 scenario checkpoints; 22 convergence points.
1513 # Anyone adding a scenario updates these by hand, on purpose. 1548 # Anyone adding a scenario updates these by hand, on purpose.
1514 [ "$OK_COUNT" = "10" ] || { 1549 [ "$OK_COUNT" = "11" ] || {
1515 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 10 —" 1550 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 11 —"
1516 echo " a scenario was added (update the pin) or silently lost" 1551 echo " a scenario was added (update the pin) or silently lost"
1517 exit 1 1552 exit 1
1518 } 1553 }
@@ -1520,4 +1555,4 @@ D5PID=""
1520 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 22" 1555 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 22"
1521 exit 1 1556 exit 1
1522 } 1557 }
1523 echo "e2e OK (10 scenarios, 22 convergence points)" 1558 echo "e2e OK (11 scenarios, 22 convergence points)"
test/ptyclient.zig
Old New
@@ -119,10 +119,206 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
119 return error.BadVerb; 119 return error.BadVerb;
120 } 120 }
121 121
122 /// Task 3 replaces this: argument parsing, pty spawn, and the verb loop. 122 // Exit codes, distinct so a scenario failure names its layer:
123 // 2 usage / setup failure
124 // 3 expect deadline passed
125 // 4 client exited before the script finished
126 // otherwise: the client's own exit status (waitexit propagates it)
127 const EXIT_USAGE: u8 = 2;
128 const EXIT_TIMEOUT: u8 = 3;
129 const EXIT_CHILD_DIED: u8 = 4;
130
131 fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn {
132 std.debug.print("ptyclient: " ++ fmt ++ "\n", args);
133 std.process.exit(code);
134 }
135
136 /// Print bytes with escapes visible: what DID arrive, when a needle did not.
137 fn dumpTail(bytes: []const u8) void {
138 const tail = if (bytes.len > 200) bytes[bytes.len - 200 ..] else bytes;
139 std.debug.print("ptyclient: last {d} bytes received: \"", .{tail.len});
140 for (tail) |b| switch (b) {
141 0x20...0x7e => std.debug.print("{c}", .{b}),
142 '\n' => std.debug.print("\\n", .{}),
143 '\r' => std.debug.print("\\r", .{}),
144 0x1b => std.debug.print("\\x1b", .{}),
145 else => std.debug.print("\\x{x:0>2}", .{b}),
146 };
147 std.debug.print("\"\n", .{});
148 }
149
150 /// Drain whatever the master has right now into the capture + expecter.
151 /// Returns false on EOF/EIO — the child side is gone.
152 fn drain(alloc: std.mem.Allocator, pty: *Pty, out: std.fs.File, exp: *Expecter) !bool {
153 var buf: [4096]u8 = undefined;
154 while (true) {
155 var fds = [_]std.posix.pollfd{
156 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
157 };
158 const ready = try std.posix.poll(&fds, 0);
159 if (ready == 0) return true;
160 const n = std.posix.read(pty.master, &buf) catch return false;
161 if (n == 0) return false;
162 try out.writeAll(buf[0..n]);
163 try exp.feed(alloc, buf[0..n]);
164 }
165 }
166
123 pub fn main() !void { 167 pub fn main() !void {
124 std.debug.print("ptyclient: script engine only — the verb loop lands with Task 3\n", .{}); 168 var dbg = std.heap.DebugAllocator(.{}){};
125 std.process.exit(2); 169 defer _ = dbg.deinit();
170 const alloc = dbg.allocator();
171
172 // --- args: --cols C --rows R --out FILE --err FILE -- argv... ---
173 var cols: u16 = 80;
174 var rows: u16 = 24;
175 var out_path: ?[]const u8 = null;
176 var err_path: ?[]const u8 = null;
177 var child_argv: std.ArrayList(?[*:0]const u8) = .empty;
178 defer child_argv.deinit(alloc);
179
180 const argv = try std.process.argsAlloc(alloc);
181 defer std.process.argsFree(alloc, argv);
182 var i: usize = 1;
183 while (i < argv.len) : (i += 1) {
184 const a = argv[i];
185 if (std.mem.eql(u8, a, "--cols")) {
186 i += 1;
187 if (i >= argv.len) fatal(EXIT_USAGE, "--cols needs a value", .{});
188 cols = std.fmt.parseInt(u16, argv[i], 10) catch
189 fatal(EXIT_USAGE, "--cols: not a number: {s}", .{argv[i]});
190 } else if (std.mem.eql(u8, a, "--rows")) {
191 i += 1;
192 if (i >= argv.len) fatal(EXIT_USAGE, "--rows needs a value", .{});
193 rows = std.fmt.parseInt(u16, argv[i], 10) catch
194 fatal(EXIT_USAGE, "--rows: not a number: {s}", .{argv[i]});
195 } else if (std.mem.eql(u8, a, "--out")) {
196 i += 1;
197 if (i >= argv.len) fatal(EXIT_USAGE, "--out needs a path", .{});
198 out_path = argv[i];
199 } else if (std.mem.eql(u8, a, "--err")) {
200 i += 1;
201 if (i >= argv.len) fatal(EXIT_USAGE, "--err needs a path", .{});
202 err_path = argv[i];
203 } else if (std.mem.eql(u8, a, "--")) {
204 for (argv[i + 1 ..]) |c| try child_argv.append(alloc, c.ptr);
205 break;
206 } else {
207 fatal(EXIT_USAGE, "unknown flag {s} (usage: ptyclient --cols C --rows R --out F --err F -- CMD...)", .{a});
208 }
209 }
210 if (child_argv.items.len == 0)
211 fatal(EXIT_USAGE, "no client command after -- (nothing to run on the pty)", .{});
212 // Sentinel-terminated by the type system, not by a trailing append the
213 // reader has to trust — and it consumes the list, so no raw pointer
214 // into a still-mutable buffer survives to the spawn.
215 const argv_z = try child_argv.toOwnedSliceSentinel(alloc, null);
216 defer alloc.free(argv_z);
217 const op = out_path orelse fatal(EXIT_USAGE, "--out is required (the capture the suite asserts on)", .{});
218 const ep = err_path orelse fatal(EXIT_USAGE, "--err is required (predict stats land there)", .{});
219
220 const out = std.fs.cwd().createFile(op, .{ .truncate = true }) catch |e|
221 fatal(EXIT_USAGE, "cannot create --out {s}: {s}", .{ op, @errorName(e) });
222 defer out.close();
223 const errf = std.fs.cwd().createFile(ep, .{ .truncate = true }) catch |e|
224 fatal(EXIT_USAGE, "cannot create --err {s}: {s}", .{ ep, @errorName(e) });
225 defer errf.close();
226
227 // Whole script up front: the harness feeds it as a heredoc and the
228 // fixture's own progress lines ("done N") are how the harness knows
229 // where the script is — the tp1 tear keys off exactly that.
230 var stdin_buf: std.ArrayList(u8) = .empty;
231 defer stdin_buf.deinit(alloc);
232 var rbuf: [4096]u8 = undefined;
233 while (true) {
234 const n = try std.posix.read(std.posix.STDIN_FILENO, &rbuf);
235 if (n == 0) break;
236 try stdin_buf.appendSlice(alloc, rbuf[0..n]);
237 }
238
239 var pty = Pty.spawnArgv(.{
240 .cols = cols,
241 .rows = rows,
242 .argv = argv_z,
243 .stderr_fd = errf.handle,
244 }) catch |e| fatal(EXIT_USAGE, "pty spawn failed: {s}", .{@errorName(e)});
245 defer pty.deinit(); // kills by tracked pid if the child is still alive
246
247 var exp: Expecter = .{};
248 defer exp.deinit(alloc);
249
250 var lines = std.mem.splitScalar(u8, stdin_buf.items, '\n');
251 var verb_no: usize = 0;
252 while (lines.next()) |raw| {
253 // The error name matters: a doubled space (empty needle) is
254 // invisible in a heredoc, and only BadVerb-vs-BadEscape tells the
255 // operator whether to look at structure or at an escape.
256 const verb = (parseLine(alloc, raw) catch |e|
257 fatal(EXIT_USAGE, "bad script line ({s}): {s}", .{ @errorName(e), raw })) orelse continue;
258 defer verb.deinit(alloc);
259 verb_no += 1;
260 switch (verb) {
261 .send => |bytes| {
262 // ONE write, asserted: the client's scroll-key parser
263 // exact-matches a whole read, so a short write here would
264 // silently turn one keystroke into two.
265 const n = std.posix.write(pty.master, bytes) catch |e|
266 fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) });
267 if (n != bytes.len)
268 fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — send payloads must fit one write", .{ verb_no, n, bytes.len });
269 },
270 .expect => |x| {
271 const start = std.time.milliTimestamp();
272 while (!exp.match(x.needle)) {
273 if (std.time.milliTimestamp() - start > x.deadline_ms) {
274 std.debug.print("ptyclient: verb {d}: expect \"{s}\" did not arrive within {d}ms\n", .{ verb_no, x.needle, x.deadline_ms });
275 dumpTail(exp.buf.items);
276 std.process.exit(EXIT_TIMEOUT);
277 }
278 var fds = [_]std.posix.pollfd{
279 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
280 };
281 _ = try std.posix.poll(&fds, 50);
282 if (!try drain(alloc, &pty, out, &exp)) {
283 if (exp.match(x.needle)) break; // arrived with the last gasp
284 std.debug.print("ptyclient: verb {d}: client closed the pty before \"{s}\" matched\n", .{ verb_no, x.needle });
285 dumpTail(exp.buf.items);
286 std.process.exit(EXIT_CHILD_DIED);
287 }
288 }
289 },
290 .resize => |r| {
291 pty.resize(r.cols, r.rows) catch |e|
292 fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) });
293 },
294 .waitexit => |deadline_ms| {
295 const start = std.time.milliTimestamp();
296 const deadline: i64 = @intCast(deadline_ms);
297 while (true) {
298 const alive = try drain(alloc, &pty, out, &exp);
299 if (pty.checkExited()) |status| {
300 if (status != 0)
301 fatal(@intCast(@min(status, 255)), "client exited {d}", .{status});
302 break;
303 }
304 if (std.time.milliTimestamp() - start > deadline)
305 fatal(EXIT_TIMEOUT, "verb {d}: client still running after {d}ms", .{ verb_no, deadline_ms });
306 if (alive) {
307 var fds = [_]std.posix.pollfd{
308 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
309 };
310 _ = try std.posix.poll(&fds, 50);
311 } else {
312 std.Thread.sleep(20 * std.time.ns_per_ms);
313 }
314 }
315 },
316 }
317 // Progress line per verb: the harness coordinates the tp1 tear by
318 // watching for "done N" in the fixture's log. std.debug.print is
319 // stderr and unbuffered, which is exactly what a barrier needs.
320 std.debug.print("ptyclient: done {d}\n", .{verb_no});
321 }
126 } 322 }
127 323
128 test "decodeEscapes: named, hex, literal backslash" { 324 test "decodeEscapes: named, hex, literal backslash" {