From 0b5ad7a30ba9e7c6eedcb426be294bd94219310f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 13:21:42 -0300 Subject: build.zig runners and testing improvements --- test/e2e_harness.zig | 32 ++- test/snapshot.zig | 399 ++++++++++++++++++++++++++++++++----- test/snapshots/chordcut.snap | 4 +- test/snapshots/cursorline.snap | 2 +- test/snapshots/dump.snap | 9 +- test/snapshots/edit.snap | 4 +- test/snapshots/exec-bigdrag.snap | 6 +- test/snapshots/exec.snap | 2 +- test/snapshots/fileedit.snap | 6 +- test/snapshots/find.snap | 2 +- test/snapshots/fsearch.snap | 4 +- test/snapshots/hscroll.snap | 2 +- test/snapshots/images.snap | 2 +- test/snapshots/layout-close.snap | 4 +- test/snapshots/layout-open.snap | 4 +- test/snapshots/leader.snap | 4 +- test/snapshots/load.snap | 4 +- test/snapshots/look-center.snap | 2 +- test/snapshots/look-dir.snap | 2 +- test/snapshots/look-file-1col.snap | 2 +- test/snapshots/look-file.snap | 2 +- test/snapshots/lookloaded.snap | 2 +- test/snapshots/lookpanes.snap | 2 +- test/snapshots/modal.snap | 4 +- test/snapshots/reflow.snap | 2 +- test/snapshots/restore.golden | 2 +- test/snapshots/rsearch.snap | 2 +- test/snapshots/shrinktag.snap | 2 +- test/snapshots/syntax.snap | 2 +- test/snapshots/tag.snap | 6 +- test/snapshots/tagcmd.snap | 6 +- test/snapshots/tagnav.golden | 4 +- test/snapshots/tagnav.snap | 4 +- test/snapshots/tagpath.snap | 2 +- test/snapshots/tinywin.snap | 10 +- test/snapshots/ttylook.snap | 2 +- test/snapshots/yankpaste.snap | 2 +- 37 files changed, 445 insertions(+), 107 deletions(-) (limited to 'test') diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index 9099f13e..eb4c7a2f 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -15,7 +15,9 @@ extern "c" fn forkpty( extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -fn nowMs() i64 { +/// Monotonic milliseconds; the harness's only clock (std.time lost +/// milliTimestamp in 0.16). +pub fn nowMs() i64 { var ts: libc.timespec = undefined; _ = libc.clock_gettime(.MONOTONIC, &ts); return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); @@ -133,6 +135,34 @@ pub const Harness = struct { if (self.trace) self.traceScreen("pump"); } + /// Wait up to `ms` for the app to say something, feed whatever arrived to + /// the emulator, and report whether anything did. `ms` = 0 polls. This is + /// the event-driven half of `pump`: a caller that only cares about the + /// SETTLED grid sleeps in poll until the app actually writes, instead of + /// burning a fixed window per check (see snapshot.zig's waitStable). + pub fn pumpOnce(self: *Harness, ms: i64) !bool { + var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }}; + const ready = posix.poll(&fds, @intCast(@max(0, ms))) catch 0; + if (ready == 0 or (fds[0].revents & posix.POLL.IN) == 0) return false; + var buf: [4096]u8 = undefined; + const n = posix.read(self.master, &buf) catch return false; + if (n == 0) return false; + self.raw.appendSlice(self.gpa, buf[0..n]) catch {}; + self.stream.nextSlice(buf[0..n]); + return true; + } + + /// Bytes sitting in the pty that nobody has picked up — keystrokes the app + /// has not read yet, or output we have not read yet (the master counts both + /// directions). Zero is the proof that the app has actually CONSUMED the + /// input just sent: without it, an app still asleep in its event loop looks + /// exactly like an app that has finished. See snapshot.zig's waitStable. + pub fn pending(self: *Harness) usize { + var n: c_int = 0; + if (posix.system.ioctl(self.master, posix.T.FIONREAD, @intFromPtr(&n)) != 0) return 0; + return if (n > 0) @intCast(n) else 0; + } + /// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows /// to the new size). Harness terminal first so it's ready for the app's /// re-render at the new size. 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 create file in the script's cwd (before start) @@ -18,8 +35,11 @@ // text send literal bytes // send send with \e \r \n \t \\ \xNN escapes // key enter esc tab bs space up down left right home end -// pgup pgdn c- a- (after esc: settle >=600ms, -// or vaxis parses the next byte as alt-) +// pgup pgdn c- a- (after esc: settle, or +// vaxis reads the next byte in the same batch and +// parses it as alt-. 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 SGR mouse, 1-based // drag motion with button held // motion 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 [scripts...] [--update]", .{}); + if (exe == null) fatal("usage: pardes-snap [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//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 { diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap index fc9ccb40..a412b57e 100644 --- a/test/snapshots/chordcut.snap +++ b/test/snapshots/chordcut.snap @@ -17,7 +17,7 @@ stable 400 5000 key i text f.txt key esc -settle 700 +settle 100 stable 400 5000 press right 6 8 release right 6 8 @@ -65,7 +65,7 @@ stable 400 5000 key i text echo B''OO g.txt key esc -settle 700 +settle 100 stable 400 5000 # 2-3 cancel (acme textselect2): tap right during the middle execute-drag # over `echo B''OO` — the command never runs (no BOO anywhere) diff --git a/test/snapshots/cursorline.snap b/test/snapshots/cursorline.snap index a7e38423..5519287e 100644 --- a/test/snapshots/cursorline.snap +++ b/test/snapshots/cursorline.snap @@ -10,7 +10,7 @@ stable 400 5000 key i text cur.txt key esc -settle 700 +settle 100 stable 400 5000 press right 6 8 release right 6 8 diff --git a/test/snapshots/dump.snap b/test/snapshots/dump.snap index 2cd2ace0..9ec0b457 100644 --- a/test/snapshots/dump.snap +++ b/test/snapshots/dump.snap @@ -1,4 +1,5 @@ -# the Dump builtin serializes the session to /tmp/pardes-dump.zon; the ZON must +# the Dump builtin serializes the session to $DUMP (this script's own dump +# file under /tmp/pardes-snap/dump/); the ZON must # be byte-identical between old and new (golden generated from the OLD binary) file d.txt x start 30 100 -n 3 @@ -10,9 +11,9 @@ stable 400 5000 key i text typed-marker key esc -settle 700 +settle 100 stable 400 5000 press middle 35 1 release middle 35 1 -settle 800 -snapfile dump /tmp/pardes-dump.zon +stable 600 8000 +snapfile dump $DUMP diff --git a/test/snapshots/edit.snap b/test/snapshots/edit.snap index 52e287d8..5a0b610e 100644 --- a/test/snapshots/edit.snap +++ b/test/snapshots/edit.snap @@ -5,11 +5,11 @@ wait 8000 e.txt stable 700 20000 press left 5 10 release left 5 10 -settle 400 +stable 400 8000 key i text hello-insert key esc -settle 700 +settle 100 stable 500 8000 snap inserted snapstyle inserted diff --git a/test/snapshots/exec-bigdrag.snap b/test/snapshots/exec-bigdrag.snap index 9158f08d..91ff1727 100644 --- a/test/snapshots/exec-bigdrag.snap +++ b/test/snapshots/exec-bigdrag.snap @@ -12,7 +12,7 @@ stable 300 5000 key i text tr -d '\n' < big.txt key esc -settle 400 +settle 100 press middle 105 8 drag middle 126 8 release middle 126 8 @@ -22,7 +22,7 @@ press middle 105 4 drag middle 150 30 drag middle 198 58 release middle 198 58 -settle 500 +stable 500 8000 stable 800 30000 # still alive: type and execute a marker command press left 105 40 @@ -31,7 +31,7 @@ stable 300 5000 key i text echo alive-o''k key esc -settle 300 +settle 100 press middle 105 40 drag middle 119 40 release middle 119 40 diff --git a/test/snapshots/exec.snap b/test/snapshots/exec.snap index a66285d5..fcbc0746 100644 --- a/test/snapshots/exec.snap +++ b/test/snapshots/exec.snap @@ -11,7 +11,7 @@ stable 400 5000 key i text echo MID''OK key esc -settle 700 +settle 100 stable 400 5000 press middle 5 8 drag middle 16 8 diff --git a/test/snapshots/fileedit.snap b/test/snapshots/fileedit.snap index 8bbe4eac..8a29b66b 100644 --- a/test/snapshots/fileedit.snap +++ b/test/snapshots/fileedit.snap @@ -9,7 +9,7 @@ stable 400 5000 key i text f.txt key esc -settle 700 +settle 100 press right 6 8 release right 6 8 wait 10000 line 3 @@ -21,7 +21,7 @@ stable 400 5000 key i text EDIT''ME key esc -settle 700 +settle 100 stable 400 5000 snap edited # x-select line 2 and delete it @@ -36,5 +36,5 @@ snap undone # Save via the tag: middle-click the Save word (after "NOR ") press middle 43 2 release middle 43 2 -settle 800 +stable 600 8000 snapfile saved /tmp/pardes-snap/fileedit/cwd/f.txt diff --git a/test/snapshots/find.snap b/test/snapshots/find.snap index bad36bb6..8be0e511 100644 --- a/test/snapshots/find.snap +++ b/test/snapshots/find.snap @@ -63,7 +63,7 @@ release middle 8 3 stable 700 10000 snap word key esc -settle 700 +settle 100 stable 400 5000 snap cancel # a no-match walk empties the buffer; n then has nowhere to go. The list is diff --git a/test/snapshots/fsearch.snap b/test/snapshots/fsearch.snap index 7953b408..c4633cda 100644 --- a/test/snapshots/fsearch.snap +++ b/test/snapshots/fsearch.snap @@ -15,7 +15,7 @@ stable 400 5000 key i text g.txt key esc -settle 700 +settle 100 stable 400 5000 press right 6 8 release right 6 8 @@ -51,7 +51,7 @@ snap back key / text zz key esc -settle 700 +settle 100 stable 400 5000 snap cancel # a no-match search: n has nowhere to go (nothing changes, no crash) diff --git a/test/snapshots/hscroll.snap b/test/snapshots/hscroll.snap index 97e8c99d..3f371147 100644 --- a/test/snapshots/hscroll.snap +++ b/test/snapshots/hscroll.snap @@ -14,7 +14,7 @@ stable 400 5000 key i text wide.txt key esc -settle 700 +settle 100 stable 400 5000 press right 7 8 release right 7 8 diff --git a/test/snapshots/images.snap b/test/snapshots/images.snap index e31d57ec..696aaec1 100644 --- a/test/snapshots/images.snap +++ b/test/snapshots/images.snap @@ -12,7 +12,7 @@ stable 400 5000 key i text pic.ppm key esc -settle 700 +settle 100 press right 6 8 release right 6 8 wait 10000 img diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap index d4160221..f353f39f 100644 --- a/test/snapshots/layout-close.snap +++ b/test/snapshots/layout-close.snap @@ -36,7 +36,7 @@ stable 400 5000 key i text exit key esc -settle 700 +settle 100 key tab stable 700 10000 snap exit @@ -48,7 +48,7 @@ stable 400 5000 key i text exit key esc -settle 700 +settle 100 key tab stable 700 10000 snap colclose diff --git a/test/snapshots/layout-open.snap b/test/snapshots/layout-open.snap index ee909f2e..36d8bb1b 100644 --- a/test/snapshots/layout-open.snap +++ b/test/snapshots/layout-open.snap @@ -16,7 +16,7 @@ key i text echo one; echo two key enter key esc -settle 700 +settle 100 stable 400 5000 snap before # Alt-n: new shell below pane 0 — the bottom pane's boundary must not move @@ -38,7 +38,7 @@ stable 400 5000 key i text sub/f.txt key esc -settle 700 +settle 100 stable 400 5000 press right 7 27 release right 7 27 diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap index 70b8a4d4..c8e13b8e 100644 --- a/test/snapshots/leader.snap +++ b/test/snapshots/leader.snap @@ -21,7 +21,7 @@ stable 400 5000 snap spc-h # Esc abandons the sequence: indicator gone, nothing ran, cursor untouched key esc -settle 700 +settle 100 stable 400 5000 snap abandoned # an UNMAPPED key abandons too, rather than waiting: a typo must not leave the @@ -85,6 +85,6 @@ snap help-window # nothing worth keeping, so the key that elsewhere only drops a selection # dismisses the whole pane and hands its height back to the pane above. key esc -settle 700 +settle 100 stable 700 15000 snap esc-del diff --git a/test/snapshots/load.snap b/test/snapshots/load.snap index 4b6372c4..561d30ee 100644 --- a/test/snapshots/load.snap +++ b/test/snapshots/load.snap @@ -19,8 +19,8 @@ wheel up 10 8 stable 500 8000 press middle 35 1 release middle 35 1 -settle 800 -start 30 100 -l /tmp/pardes-dump.zon +stable 600 8000 +start 30 100 -l $DUMP wait 8000 LOADMARK stable 700 20000 snap loaded diff --git a/test/snapshots/look-center.snap b/test/snapshots/look-center.snap index 8bd3ea72..1b11bfd8 100644 --- a/test/snapshots/look-center.snap +++ b/test/snapshots/look-center.snap @@ -12,7 +12,7 @@ stable 400 5000 key i text big.txt:100 big.txt:150 big.txt:2 key esc -settle 700 +settle 100 stable 400 5000 press right 7 8 release right 7 8 diff --git a/test/snapshots/look-dir.snap b/test/snapshots/look-dir.snap index 38023fbd..918a5de5 100644 --- a/test/snapshots/look-dir.snap +++ b/test/snapshots/look-dir.snap @@ -11,7 +11,7 @@ stable 400 5000 key i text sub key esc -settle 700 +settle 100 stable 400 5000 press right 6 8 release right 6 8 diff --git a/test/snapshots/look-file-1col.snap b/test/snapshots/look-file-1col.snap index 3ef7e579..ce0ec78d 100644 --- a/test/snapshots/look-file-1col.snap +++ b/test/snapshots/look-file-1col.snap @@ -11,7 +11,7 @@ stable 400 5000 key i text big.txt:40 key esc -settle 700 +settle 100 stable 400 5000 press right 7 8 release right 7 8 diff --git a/test/snapshots/look-file.snap b/test/snapshots/look-file.snap index 81cf14cb..4b8b39b7 100644 --- a/test/snapshots/look-file.snap +++ b/test/snapshots/look-file.snap @@ -10,7 +10,7 @@ stable 400 5000 key i text big.txt:40 key esc -settle 700 +settle 100 stable 400 5000 press right 7 8 release right 7 8 diff --git a/test/snapshots/lookloaded.snap b/test/snapshots/lookloaded.snap index 89106d25..68674ba2 100644 --- a/test/snapshots/lookloaded.snap +++ b/test/snapshots/lookloaded.snap @@ -12,7 +12,7 @@ stable 400 5000 key i text big.txt:40 big.txt:10 key esc -settle 700 +settle 100 stable 400 5000 press right 7 8 release right 7 8 diff --git a/test/snapshots/lookpanes.snap b/test/snapshots/lookpanes.snap index 98648226..64bb4a98 100644 --- a/test/snapshots/lookpanes.snap +++ b/test/snapshots/lookpanes.snap @@ -34,7 +34,7 @@ stable 400 5000 key i text zqonly.txt pick.txt twin.txt key esc -settle 700 +settle 100 stable 400 5000 snap shells # make the beta pane the most recently focused, so the walk has to pass it diff --git a/test/snapshots/modal.snap b/test/snapshots/modal.snap index 68809e18..b6769fdd 100644 --- a/test/snapshots/modal.snap +++ b/test/snapshots/modal.snap @@ -35,11 +35,11 @@ stable 400 5000 snap vsel snapstyle vsel key esc -settle 700 +settle 100 key i text TYPED key esc -settle 700 +settle 100 stable 400 5000 snap typed key u diff --git a/test/snapshots/reflow.snap b/test/snapshots/reflow.snap index 0fa4a30d..931fe9d4 100644 --- a/test/snapshots/reflow.snap +++ b/test/snapshots/reflow.snap @@ -8,7 +8,7 @@ start 24 40 wait 8000 $ text printf 'y%.0s' $(seq 1 3000); echo do''ne key enter -settle 2500 +stable 600 15000 stable 700 15000 wheel up 10 10 wheel up 10 10 diff --git a/test/snapshots/restore.golden b/test/snapshots/restore.golden index 611b9fc6..4ca6cedf 100644 --- a/test/snapshots/restore.golden +++ b/test/snapshots/restore.golden @@ -1,5 +1,5 @@ == snap dumped grid=100x30 cursor=4,5 -|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon +|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-snap/restore/dump.zon | TTY /tmp/pardes-snap/restore/cwd Del | $ ls | $ echo KEEP''ME diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap index 16f40137..669483e9 100644 --- a/test/snapshots/rsearch.snap +++ b/test/snapshots/rsearch.snap @@ -17,7 +17,7 @@ stable 400 5000 key i text code.txt key esc -settle 700 +settle 100 stable 400 5000 press right 6 8 release right 6 8 diff --git a/test/snapshots/shrinktag.snap b/test/snapshots/shrinktag.snap index d82f8192..94d36282 100644 --- a/test/snapshots/shrinktag.snap +++ b/test/snapshots/shrinktag.snap @@ -13,7 +13,7 @@ key i text echo one; echo two key enter key esc -settle 700 +settle 100 stable 400 5000 snap before # drag pane 0's bottom handle (its last row) up to its tag row: tag-only pane diff --git a/test/snapshots/syntax.snap b/test/snapshots/syntax.snap index 0aa85b71..d660fdd9 100644 --- a/test/snapshots/syntax.snap +++ b/test/snapshots/syntax.snap @@ -10,7 +10,7 @@ stable 400 5000 key i text demo.zig key esc -settle 700 +settle 100 press right 6 8 release right 6 8 wait 10000 @import diff --git a/test/snapshots/tag.snap b/test/snapshots/tag.snap index 4094e841..079244e1 100644 --- a/test/snapshots/tag.snap +++ b/test/snapshots/tag.snap @@ -12,7 +12,7 @@ release left 40 2 stable 400 5000 snap tag-focus send \x20t.txt -settle 700 +stable 600 8000 stable 400 5000 snap tag-typed key bs @@ -22,11 +22,11 @@ key bs stable 400 5000 snap tag-del send .txt -settle 700 +stable 600 8000 stable 400 5000 # Esc: out of the tag, body back in normal mode, typed tail stays as text key esc -settle 700 +settle 100 stable 400 5000 snap tag-exit # re-enter the tag and look the typed word: Enter opens t.txt as a file pane diff --git a/test/snapshots/tagcmd.snap b/test/snapshots/tagcmd.snap index d7d002ff..d5317b8d 100644 --- a/test/snapshots/tagcmd.snap +++ b/test/snapshots/tagcmd.snap @@ -14,7 +14,7 @@ stable 700 20000 key i text ZZEDIT key esc -settle 700 +settle 100 stable 400 5000 snap edited # `:` -> the tag, in normal mode, cursor on the tail's first cell @@ -27,7 +27,7 @@ stable 400 5000 snap word # Tab executes it: c.txt is written and focus is back in the body key tab -settle 800 +stable 600 8000 stable 400 5000 snap saved snapfile written /tmp/pardes-snap/tagcmd/cwd/c.txt @@ -35,6 +35,6 @@ snapfile written /tmp/pardes-snap/tagcmd/cwd/c.txt key : stable 400 5000 key esc -settle 700 +settle 100 stable 400 5000 snap escaped diff --git a/test/snapshots/tagnav.golden b/test/snapshots/tagnav.golden index 77d5a846..6ea682d2 100644 --- a/test/snapshots/tagnav.golden +++ b/test/snapshots/tagnav.golden @@ -495,7 +495,7 @@ | | == snap topbar-exec grid=100x30 cursor=7,2 -|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon +|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-snap/tagnav/dump.zon | NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del | 1 alpha $ ls | 2 beta w.txt @@ -526,7 +526,7 @@ | | == snap topbar-restore-word grid=100x30 cursor=39,0 -|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon +|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-snap/tagnav/dump.zon | NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del | 1 alpha $ ls | 2 beta w.txt diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap index fbb9c917..3da71cd9 100644 --- a/test/snapshots/tagnav.snap +++ b/test/snapshots/tagnav.snap @@ -69,7 +69,7 @@ key right right stable 400 5000 snap arrows-still-motion key esc -settle 700 +settle 100 stable 400 5000 snap back-to-body # A TERMINAL MUST NOT LOSE ITS TTY MODE. Put the launch shell in raw tty mode @@ -87,7 +87,7 @@ key l stable 400 5000 snap tty-tag key esc -settle 700 +settle 100 stable 400 5000 snap tty-restored # Enter/Tab on the BAR runs the word under the cursor through the very dispatch diff --git a/test/snapshots/tagpath.snap b/test/snapshots/tagpath.snap index 61181dd5..e4517ddc 100644 --- a/test/snapshots/tagpath.snap +++ b/test/snapshots/tagpath.snap @@ -37,7 +37,7 @@ snapstyle sel-path # the file, which is the only way a snapshot can see what was yanked. key y key esc -settle 700 +settle 100 stable 400 5000 key p stable 400 5000 diff --git a/test/snapshots/tinywin.snap b/test/snapshots/tinywin.snap index fab7f950..95050301 100644 --- a/test/snapshots/tinywin.snap +++ b/test/snapshots/tinywin.snap @@ -5,11 +5,11 @@ start 30 100 wait 8000 $ key a-n -settle 400 +stable 400 8000 key a-n -settle 400 +stable 400 8000 key a-n -settle 400 +stable 400 8000 key a-n stable 700 15000 snap stacked @@ -20,9 +20,9 @@ snap shrunk resize 30 100 stable 700 10000 key space c n -settle 600 +stable 600 8000 key space c n -settle 600 +stable 600 8000 key space c n stable 700 15000 snap columns diff --git a/test/snapshots/ttylook.snap b/test/snapshots/ttylook.snap index f22cd0d1..cc746970 100644 --- a/test/snapshots/ttylook.snap +++ b/test/snapshots/ttylook.snap @@ -29,7 +29,7 @@ snap tokback key i text nn key esc -settle 700 +settle 100 stable 400 5000 snap insert key u diff --git a/test/snapshots/yankpaste.snap b/test/snapshots/yankpaste.snap index 96082ad6..3445fe98 100644 --- a/test/snapshots/yankpaste.snap +++ b/test/snapshots/yankpaste.snap @@ -31,7 +31,7 @@ snap charwise-inline # y then p. It pastes as one empty line. key o key esc -settle 700 +settle 100 key y p stable 400 5000 snap blankline -- cgit v1.3