summaryrefslogtreecommitdiff
path: root/test/snapshot.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-28 13:21:42 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit0b5ad7a30ba9e7c6eedcb426be294bd94219310f (patch)
tree1a991acfb2a14f25ece19e13e65915dbbb48c7fc /test/snapshot.zig
parent95202b9b22751c86f100aae9ff1c64e535dcbd70 (diff)
downloadpardes-0b5ad7a30ba9e7c6eedcb426be294bd94219310f.tar.gz
pardes-0b5ad7a30ba9e7c6eedcb426be294bd94219310f.zip
build.zig runners and testing improvements
Diffstat (limited to 'test/snapshot.zig')
-rw-r--r--test/snapshot.zig399
1 files changed, 353 insertions, 46 deletions
diff --git a/test/snapshot.zig b/test/snapshot.zig
index f1ebd941..31f2c8ea 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -6,6 +6,23 @@
// zig build snap run all snapshots/*.snap, diff goldens
// zig build snap -- --update regenerate goldens from this binary
// zig build snap -- snapshots/boot.snap one script
+// zig build snap -- --jobs=1 serial (default: 1.25 per cpu)
+// zig build snap -- --trace-stable show what every `stable` waited on
+//
+// Scripts run one per forked child, in parallel; each child's report is held
+// in a temp file and printed in script order at the end. The runner takes its
+// own lockfile, so `zig build snap` never needs an `flock` wrapper: a second
+// run waits for the first instead of trampling the fixed /tmp paths that the
+// goldens record verbatim. A script that fails in the parallel pass is re-run
+// alone before it counts as a failure.
+//
+// `stable` no longer burns its wall-clock window: it returns as soon as the
+// app is provably done — screen unchanged, pty empty, and every process it
+// forked asleep (see waitStable). That, the fan-out, and building the vendored
+// C optimized (build.zig's c_optimize: 90% of a Debug startup used to be
+// tree-sitter's query analyser at -O0) took the suite from 7m54s to ~4s. The
+// app now reaches first paint in ~25ms, so what is left is mostly real work:
+// bash spawns, their output, and the 40 `settle`s after `key esc`.
//
// Script commands (line-oriented, # comments):
// file <name> <content> create file in the script's cwd (before start)
@@ -18,8 +35,11 @@
// text <literal> send literal bytes
// send <escaped> send with \e \r \n \t \\ \xNN escapes
// key <names...> enter esc tab bs space up down left right home end
-// pgup pgdn c-<ch> a-<ch> (after esc: settle >=600ms,
-// or vaxis parses the next byte as alt-<ch>)
+// pgup pgdn c-<ch> a-<ch> (after esc: settle, or
+// vaxis reads the next byte in the same batch and
+// parses it as alt-<ch>. The scripts said 700ms and
+// it cost the suite 28s; there is no timer in vaxis
+// to outlast — 20ms passes, they use 100ms)
// press|release <left|middle|right> <col> <row> SGR mouse, 1-based
// drag <btn> <col> <row> motion with button held
// motion <col> <row> button-less motion (hover)
@@ -44,6 +64,9 @@ extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
const SNAP_BASE = "/tmp/pardes-snap";
+var trace_stable = false;
+/// set for the serial re-run pass; children widen their probes (see beCareful)
+var careful = false;
const gpa = std.heap.page_allocator;
pub fn main(init: std.process.Init) !void {
@@ -60,17 +83,27 @@ pub fn main(init: std.process.Init) !void {
var exe: ?[]const u8 = null;
var update = false;
+ // Slight oversubscription: a script spends most of its life waiting on a
+ // shell, not computing. Measured on 16 cores: 16 jobs 4.4s, 20 jobs 3.9s,
+ // 24 jobs 3.7s but losing the idle-probe bet often enough that the re-run
+ // pass costs more than the extra parallelism saves.
+ const cpus = std.Thread.getCpuCount() catch 4;
+ var jobs: usize = cpus + cpus / 4;
var scripts: std.ArrayList([]const u8) = .empty;
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update")) {
update = true;
+ } else if (std.mem.eql(u8, a, "--trace-stable")) {
+ trace_stable = true;
+ } else if (std.mem.startsWith(u8, a, "--jobs=")) {
+ jobs = @max(1, try std.fmt.parseInt(usize, a["--jobs=".len..], 10));
} else if (exe == null) {
exe = try absPath(arena, orig_cwd, a);
} else {
try scripts.append(arena, try absPath(arena, orig_cwd, a));
}
}
- if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update]", .{});
+ if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update] [--jobs=N]", .{});
const exe_z = try arena.dupeZ(u8, exe.?);
if (scripts.items.len == 0) {
@@ -83,34 +116,49 @@ pub fn main(init: std.process.Init) !void {
}
if (scripts.items.len == 0) fatal("no .snap scripts found", .{});
+ try mkdir(arena, SNAP_BASE, true);
+ // one run at a time (this is the flock that used to be typed by hand)
+ _ = lockOrWait(SNAP_BASE ++ "/.run.lock", "another pardes-snap run");
+
+ const codes = try arena.alloc(u8, scripts.items.len);
+ try runBatch(arena, exe_z, toBatch(arena, scripts.items), update, jobs, codes);
+
+ // A failure in the parallel pass is more often contention than regression:
+ // the app can lose a race with a kworker while 20 siblings fight for the
+ // cpu. So a failure is re-run alone before it counts — first at the normal
+ // probe settings, which is usually all an idle machine needs, and only if
+ // it fails AGAIN with the probes widened (beCareful), which is slow but
+ // decides. A wholesale failure is not flakiness, so past a handful there is
+ // nothing to re-check.
+ if (jobs > 1) {
+ var round: usize = 0;
+ while (round < 2) : (round += 1) {
+ var retry: std.ArrayList(usize) = .empty;
+ for (codes, 0..) |c, i| if (c != 0) try retry.append(arena, i);
+ if (retry.items.len == 0 or retry.items.len > 10) break;
+ const paths = try arena.alloc(Idx, retry.items.len);
+ for (retry.items, paths, 0..) |i, *p, n| p.* = .{ .path = scripts.items[i], .code_idx = n };
+ std.debug.print("re-running {d} failed script(s) serially{s}:", .{
+ paths.len,
+ if (round == 0) "" else " with wider settle probes",
+ });
+ for (paths) |p| std.debug.print(" {s}", .{std.fs.path.stem(p.path)});
+ std.debug.print("\n", .{});
+ careful = round > 0;
+ const recodes = try arena.alloc(u8, paths.len);
+ try runBatch(arena, exe_z, paths, update, 1, recodes);
+ for (retry.items, recodes) |i, c| codes[i] = c;
+ }
+ }
+
var failed: usize = 0;
- for (scripts.items) |script_path| {
- const stem = std.fs.path.stem(script_path);
- const base_path = script_path[0 .. script_path.len - ".snap".len];
- const golden_path = try std.fmt.allocPrint(arena, "{s}.golden", .{base_path});
- const out = runScript(arena, exe_z, script_path, stem) catch |e| {
- std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) });
+ for (scripts.items, codes) |script_path, code| {
+ const report = readFileAlloc(arena, reportPath(arena, script_path) catch "") catch "";
+ std.debug.print("{s}", .{report});
+ if (code != 0) {
failed += 1;
- continue;
- };
- if (update) {
- try eh.writeFile(try arena.dupeZ(u8, golden_path), out);
- std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len });
- } else {
- const golden = readFileAlloc(arena, golden_path) catch {
- std.debug.print("FAIL {s}: no golden (run with -- --update)\n", .{stem});
- failed += 1;
- continue;
- };
- if (std.mem.eql(u8, golden, out)) {
- std.debug.print("PASS {s}\n", .{stem});
- } else {
- const actual_path = try std.fmt.allocPrint(arena, "{s}.actual", .{base_path});
- try eh.writeFile(try arena.dupeZ(u8, actual_path), out);
- std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
- printFirstDiff(golden, out);
- failed += 1;
- }
+ if (report.len == 0)
+ std.debug.print("FAIL {s}: child died with no report\n", .{std.fs.path.stem(script_path)});
}
}
if (failed > 0) {
@@ -120,6 +168,127 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len});
}
+/// Run `scripts` `jobs` at a time, one forked child each, filling `codes` with
+/// their exit codes. Forking rather than threading because runScript owns
+/// process-global state — chdir into its workdir, HOME/TERM/PARDES_DUMP in the
+/// environment — which siblings must not see. Each child's report goes to its
+/// own file and is replayed in script order by the caller, so parallelism
+/// never interleaves a diff.
+// Scripts run in the order given — alphabetical. Sorting longest-first (from
+// the durations the previous run left in its reports) is the textbook makespan
+// fix and it measured WORSE here: 3.0s when clean, but bunching the heavy
+// scripts at t=0 makes them contend, the idle probes lose their bet, and the
+// re-run pass costs more than the tail ever did (13-20s runs). Left alone.
+const Idx = struct { path: []const u8, code_idx: usize };
+
+fn toBatch(arena: std.mem.Allocator, scripts: []const []const u8) []const Idx {
+ const out = arena.alloc(Idx, scripts.len) catch return &.{};
+ for (scripts, out, 0..) |path, *o, i| o.* = .{ .path = path, .code_idx = i };
+ return out;
+}
+
+fn runBatch(
+ arena: std.mem.Allocator,
+ exe_z: [:0]const u8,
+ scripts: []const Idx,
+ update: bool,
+ jobs: usize,
+ codes: []u8,
+) !void {
+ const Job = struct { pid: libc.pid_t, idx: usize };
+ var running: std.ArrayList(Job) = .empty;
+ defer running.deinit(arena);
+ var next: usize = 0;
+ while (next < scripts.len or running.items.len > 0) {
+ while (next < scripts.len and running.items.len < jobs) : (next += 1) {
+ const idx = next;
+ const pid = libc.fork();
+ if (pid == 0) std.process.exit(runOne(arena, exe_z, scripts[idx].path, update));
+ if (pid < 0) fatal("fork failed", .{});
+ try running.append(arena, .{ .pid = pid, .idx = scripts[idx].code_idx });
+ }
+ var status: c_int = 0;
+ const pid = libc.waitpid(-1, &status, 0);
+ for (running.items, 0..) |j, i| {
+ if (j.pid != pid) continue;
+ // a killed child (crash in the harness itself) counts as a failure
+ codes[j.idx] = if (status == 0) 0 else 1;
+ _ = running.swapRemove(i);
+ break;
+ }
+ }
+}
+
+/// One script, in its own process: run it, then diff or update its golden.
+/// Everything it prints lands in its report file (dup2'd over stdout/stderr).
+/// Returns the child's exit code.
+fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8, update: bool) u8 {
+ if (careful) beCareful();
+ const stem = std.fs.path.stem(script_path);
+ const report_z = arena.dupeZ(u8, reportPath(arena, script_path) catch return 1) catch return 1;
+ const fd = libc.open(report_z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd >= 0) {
+ _ = libc.dup2(fd, 1);
+ _ = libc.dup2(fd, 2);
+ _ = libc.close(fd);
+ }
+ const base_path = script_path[0 .. script_path.len - ".snap".len];
+ const golden_path = std.fmt.allocPrint(arena, "{s}.golden", .{base_path}) catch return 1;
+ const golden: ?[]u8 = readFileAlloc(arena, golden_path) catch null;
+ const started = eh.nowMs();
+ const out = runScript(arena, exe_z, script_path, stem) catch |e| {
+ std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) });
+ return 1;
+ };
+ const took = eh.nowMs() - started;
+ if (update) {
+ eh.writeFile(arena.dupeZ(u8, golden_path) catch return 1, out) catch return 1;
+ std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len });
+ return 0;
+ }
+ if (golden == null) {
+ std.debug.print("FAIL {s}: no golden (run with -- --update)\n", .{stem});
+ return 1;
+ }
+ if (std.mem.eql(u8, golden.?, out)) {
+ std.debug.print("PASS {s} ({d}ms)\n", .{ stem, took });
+ return 0;
+ }
+ const actual_path = std.fmt.allocPrint(arena, "{s}.actual", .{base_path}) catch return 1;
+ eh.writeFile(arena.dupeZ(u8, actual_path) catch return 1, out) catch {};
+ std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
+ printFirstDiff(golden.?, out);
+ return 1;
+}
+
+fn reportPath(arena: std.mem.Allocator, script_path: []const u8) ![]const u8 {
+ return std.fmt.allocPrint(arena, "{s}/{s}.report", .{ SNAP_BASE, std.fs.path.stem(script_path) });
+}
+
+/// Exclusive flock on `path`, announcing the wait if someone else holds it.
+/// The fd is deliberately leaked: the lock lives until the process exits.
+///
+/// CLOEXEC is load-bearing. flock lives on the open file description, so an
+/// inherited fd keeps the lock — and this process forks a pty full of app and
+/// shell processes that can outlive it. Without CLOEXEC an orphaned bash holds
+/// the lock forever and every later script blocks in flock().
+fn lockOrWait(path: [*:0]const u8, what: []const u8) libc.fd_t {
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .CLOEXEC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) fatal("cannot open lockfile {s}", .{path});
+ if (libc.flock(fd, std.posix.LOCK.EX | std.posix.LOCK.NB) == 0) return fd;
+ // polled rather than a blocking flock so a stuck holder is reported instead
+ // of hanging the run until someone notices. The cap clears a full serial
+ // run (--jobs=1 is ~6 minutes) with room to spare.
+ std.debug.print("waiting for {s}...\n", .{what});
+ var waited_ms: i64 = 0;
+ while (waited_ms < 600_000) : (waited_ms += 100) {
+ var none: [0]std.posix.pollfd = .{};
+ _ = std.posix.poll(&none, 100) catch {};
+ if (libc.flock(fd, std.posix.LOCK.EX | std.posix.LOCK.NB) == 0) return fd;
+ }
+ fatal("gave up after 10 minutes waiting for {s} ({s})", .{ what, path });
+}
+
fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8, stem: []const u8) ![]u8 {
const src = try readFileAlloc(arena, script_path);
@@ -138,10 +307,14 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
_ = setenv("HOME", try arena.dupeZ(u8, home), 1);
_ = setenv("TERM", "xterm-256color", 1);
_ = setenv("LC_ALL", "C", 1);
- // pin the dump path: live runs use a timestamped file under the user data
+ // Pin the dump path: live runs use a timestamped file under the user data
// dir (dump.outPath), which would make the dump/load/restore goldens
- // nondeterministic
- _ = setenv("PARDES_DUMP", "/tmp/pardes-dump.zon", 1);
+ // nondeterministic. Per-SCRIPT, not one shared /tmp/pardes-dump.zon: four
+ // scripts write it and read it back, and sharing it meant they could only
+ // take turns — which cost more wall clock than the other 51 scripts put
+ // together. Scripts name it `$DUMP`.
+ const dump_path = try std.fmt.allocPrint(arena, "{s}/dump.zon", .{base});
+ _ = setenv("PARDES_DUMP", try arena.dupeZ(u8, dump_path), 1);
if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed;
var out: std.ArrayList(u8) = .empty;
@@ -152,8 +325,14 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
var lineno: usize = 0;
while (lines_it.next()) |raw_line| {
lineno += 1;
- const line = std.mem.trim(u8, raw_line, " \t\r");
- if (line.len == 0 or line[0] == '#') continue;
+ const trimmed = std.mem.trim(u8, raw_line, " \t\r");
+ if (trimmed.len == 0 or trimmed[0] == '#') continue;
+ // `$DUMP` is the script's own PARDES_DUMP file (see above)
+ const line = if (std.mem.indexOf(u8, trimmed, "$DUMP") == null) trimmed else blk: {
+ const buf = try arena.alloc(u8, std.mem.replacementSize(u8, trimmed, "$DUMP", dump_path));
+ _ = std.mem.replace(u8, trimmed, "$DUMP", dump_path, buf);
+ break :blk buf;
+ };
var tok = std.mem.tokenizeScalar(u8, line, ' ');
const cmd = tok.next().?;
errdefer std.debug.print(" at {s}:{d}: {s}\n", .{ script_path, lineno, line });
@@ -275,25 +454,153 @@ fn live(h: *?eh.Harness) *eh.Harness {
fatal("script used the app before `start`", .{});
}
-/// Pump until the grid is unchanged for `quiet` ms (bounded by `timeout`).
-/// The one sync primitive that needs no needle — used before every snap so
-/// old and new binaries are compared at rest, not mid-repaint.
+/// Pump until the captured state — grid text AND cursor — has not changed for
+/// `quiet` ms (bounded by `timeout`). The one sync primitive that needs no
+/// needle: used before every snap so old and new binaries are compared at
+/// rest, not mid-repaint.
+///
+/// Same oracle as before, timed continuously instead of in fixed windows. The
+/// old loop pumped a whole `quiet` window, compared, and pumped another — so
+/// even an app that fell silent immediately cost 2 × 700ms, and 446 `stable`s
+/// cost the suite ten minutes. This one sleeps in poll until the app writes,
+/// and returns `quiet` ms after the LAST change. Waiting on the change rather
+/// than on silence is what keeps it honest: pardes finishes a repaint with a
+/// late cursor move, hundreds of ms after its last burst of text.
fn waitStable(h: *eh.Harness, quiet: i64, timeout: i64) !void {
- var total: i64 = 0;
- var prev = try h.screenText();
+ const t0 = eh.nowMs();
+ const deadline = t0 + timeout;
+ var prev = try stateKey(h);
defer h.gpa.free(prev);
- while (total < timeout) {
- try h.pump(quiet);
- total += quiet;
- const cur = try h.screenText();
- if (std.mem.eql(u8, prev, cur)) {
- h.gpa.free(cur);
+ var last_change = t0;
+ var asleep_since: i64 = 0;
+ while (true) {
+ const now = eh.nowMs();
+ const since = now - last_change;
+ if (since >= quiet) {
+ if (trace_stable) std.debug.print("[stable q={d}] window: last change +{d}ms, cost {d}ms\n", .{ quiet, last_change - t0, now - t0 });
return;
}
+ // Fast path: the screen has held still, the pty is empty in both
+ // directions (so the app has CONSUMED everything the script just sent
+ // — otherwise an app still asleep in its event loop looks finished),
+ // and nothing in its process tree is runnable. Held across two probes,
+ // because a pane's shell can write and sleep a scheduler tick before
+ // the app wakes to read it.
+ if (since >= probe_after_ms and h.pending() == 0 and treeAsleep(h.pid)) {
+ if (asleep_since == 0) {
+ asleep_since = now;
+ } else if (now - asleep_since >= probe_hold_ms) {
+ if (trace_stable) std.debug.print("[stable q={d}] asleep: last change +{d}ms, cost {d}ms\n", .{ quiet, last_change - t0, now - t0 });
+ return;
+ }
+ } else if (since >= probe_after_ms) {
+ asleep_since = 0;
+ }
+ if (now >= deadline) return error.NeverStable;
+ const nap = if (since >= probe_after_ms) probe_every_ms else probe_after_ms - since;
+ if (!try h.pumpOnce(@min(nap, deadline - now))) continue;
+ while (try h.pumpOnce(0)) {} // drain the burst before comparing
+ const cur = try stateKey(h);
+ if (std.mem.eql(u8, cur, prev)) {
+ h.gpa.free(cur);
+ continue;
+ }
h.gpa.free(prev);
prev = cur;
+ if (trace_stable) std.debug.print("[stable q={d}] change at +{d}ms (gap {d}ms)\n", .{ quiet, eh.nowMs() - t0, eh.nowMs() - last_change });
+ last_change = eh.nowMs();
+ asleep_since = 0;
+ }
+}
+
+// How long the screen must hold still before the sleep probe is trusted at all,
+// how often it is taken, and how long it must stay true.
+//
+// The hold is the one number that cannot be reasoned down to zero. When the app
+// writes to a pane's pty the kernel parks the bytes in a flip buffer and leaves
+// the hand-off to a kworker: until that runs, the app is asleep, the shell is
+// asleep, nothing is runnable and no /proc or ioctl query can see the round trip
+// in flight (a pty master cannot even be reopened through /proc/<pid>/fd — it
+// mints a fresh pty). So the hold is a bet on kworker latency, and a loaded
+// machine loses it. The parallel pass bets small; the serial re-run that decides
+// the verdict bets big.
+var probe_after_ms: i64 = 10;
+const probe_every_ms: i64 = 5;
+var probe_hold_ms: i64 = 50;
+
+/// Widen the probes for the re-run pass: nothing else is competing then, so the
+/// extra wait is cheap, and its verdict is the one that gets reported.
+fn beCareful() void {
+ probe_after_ms = 40;
+ probe_hold_ms = 300;
+}
+
+/// Is every process in the app's tree asleep? Walks /proc from the app's pid
+/// through `children`, checking every thread's run state: `R` (runnable) or
+/// `D` (uninterruptible) anywhere means work is still in flight — a repaint,
+/// a fork, a shell mid-command — so a quiet pty proves nothing. Anything else
+/// (`S`, or a zombie waiting to be reaped) is idle.
+///
+/// This is what replaces the wall-clock window: the app finishes a keystroke
+/// in ~10ms, and the scripts' 400-700ms `stable` windows were insurance
+/// against not being able to tell "finished" from "paused mid-fork".
+fn treeAsleep(root: libc.pid_t) bool {
+ var stack: [64]libc.pid_t = undefined;
+ stack[0] = root;
+ var n: usize = 1;
+ var path_buf: [64:0]u8 = undefined;
+ var buf: [4096]u8 = undefined;
+ while (n > 0) {
+ n -= 1;
+ const pid = stack[n];
+ const dir_path = std.fmt.bufPrintZ(&path_buf, "/proc/{d}/task", .{pid}) catch continue;
+ const dir = libc.opendir(dir_path) orelse continue; // exited between steps
+ defer _ = libc.closedir(dir);
+ while (libc.readdir(dir)) |d| {
+ const tid = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d.name)), 0);
+ if (tid[0] == '.') continue;
+ const stat = readProc(&buf, std.fmt.bufPrintZ(&path_buf, "/proc/{d}/task/{s}/stat", .{ pid, tid }) catch continue) orelse continue;
+ // "pid (comm) S ..." — comm can hold spaces and parens, so the
+ // state is the char two past the LAST ')'
+ const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse continue;
+ if (close + 2 >= stat.len) continue;
+ switch (stat[close + 2]) {
+ // R/D: still working. Z: just exited, so its parent is about to
+ // be woken to reap it and carry on — a pane's shell sitting in
+ // wait4 over a zombie `ls` looks asleep, but the prompt it is
+ // about to print is what hides the echoed command line in the
+ // goldens.
+ 'R', 'D', 'Z' => return false,
+ else => {},
+ }
+ const kids = readProc(&buf, std.fmt.bufPrintZ(&path_buf, "/proc/{d}/task/{s}/children", .{ pid, tid }) catch continue) orelse continue;
+ var it = std.mem.tokenizeAny(u8, kids, " \n");
+ while (it.next()) |k| {
+ if (n >= stack.len) return false; // deeper than we can walk: assume busy
+ stack[n] = std.fmt.parseInt(libc.pid_t, k, 10) catch continue;
+ n += 1;
+ }
+ }
}
- return error.NeverStable;
+ return true;
+}
+
+/// Read a small /proc file into `buf` (procfs needs one read, no stat).
+fn readProc(buf: []u8, path: [*:0]const u8) ?[]const u8 {
+ const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
+ if (fd < 0) return null;
+ defer _ = libc.close(fd);
+ const got = libc.read(fd, buf.ptr, buf.len);
+ if (got <= 0) return null;
+ return buf[0..@intCast(got)];
+}
+
+/// What a `snap` capture is made of: the grid text plus the cursor cell.
+fn stateKey(h: *eh.Harness) ![]u8 {
+ const text = try h.screenText();
+ defer h.gpa.free(text);
+ const c = h.cursor();
+ return std.fmt.allocPrint(h.gpa, "{d},{d}\n{s}", .{ c.x, c.y, text });
}
fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {