00d2f8fb
feat: ptyclient main loop + fixture controls in the suite
a73x 2026-08-10 07:52
Commit message
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" { |