From a15c05b3d15bc269b3aec195c5e813a0f0203d4f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 18:36:10 -0300 Subject: The 9P server offers 64 KiB frames, not 8 KiB 8192 was kept for round 24 because 64 KiB seemed to hang fs-test's restore detached space; that was the batch-wake overflow fixed in kmqtwqsm, which any msize could hit, and fs.py passes at 64 KiB with it. A client asking for more now gets it, so a write up to 64 KiB less 24 is one Twrite, and round 24's no-newline cutoff (write_room, the negotiated msize less 24) follows: fs.py checks a 20000-byte ctl line from a 64 KiB client is taken whole. Each of the 16 connection slots holds its buffers at the larger size. Co-Authored-By: Claude Opus 5.5 --- test/fs.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'test') diff --git a/test/fs.py b/test/fs.py index db10f32d..18563183 100644 --- a/test/fs.py +++ b/test/fs.py @@ -953,6 +953,20 @@ def test(binary, quic=False): with Client(address, msize=256) as small: assert set(small.list('/os' + str(listing))) == { f'entry-{number:02}' for number in range(24)} + # 64 KiB frames: a ctl line with no newline, whole in its one + # Twrite, runs with it however long -- the cutoff for a write + # that may go on is the frame the client asked for, less 24. + with Client(address, msize=65536) as big: + assert big.msize == 65536 + ctl = big.open('/pane/1/ctl', 1) + line = b'bogus' + b'x' * 20000 + try: + big.rpc(118, struct.pack(' Date: Tue, 29 Sep 2026 19:49:19 -0300 Subject: Over a slow link the tty keeps one frame in flight: it asks the terminal to answer after each, and draws the latest state when it does --- src/tty/tty.zig | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++- test/e2e_harness.zig | 28 +++++++-- 2 files changed, 194 insertions(+), 6 deletions(-) (limited to 'test') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index df1e5d02..f5ae078a 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -132,7 +132,14 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (carried == buf.len) return error.InputSequenceTooLong; const received = try tty.read(buf[carried..]); if (received == 0) return; - const end = carried + received; + // The terminal's answers to frames (FrameAck) come out first, + // wherever they sit: never events, and never glued to a key (an + // Escape pressed just before one would read as Alt on it). + const end = FrameAck.take(buf[0 .. carried + received], loop); + if (end == 0) { + carried = 0; + continue; + } if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| { kitty_shm_probing.store(false, .release); kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release); @@ -153,7 +160,8 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { // its writes whole, so no sequence arrives cut right after its ESC. // Waiting on it cost every Escape press 50 ms on a legacy terminal // (tmux, most ssh sessions), and 1 s before kitty's flags were on. - var waited = carried == 0 and received == 1; + // (A terminal's answer to a frame is one write too: FrameAck.) + var waited = carried == 0 and end == 1; while (consumed < parse_end) { // A lone ESC that ends a longer read is the Escape key, or the // first byte of a sequence whose rest the read boundary held @@ -162,6 +170,9 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { // kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC // can only start a sequence and the wait can be long. const rest = buf[consumed..parse_end]; + // A frame's answer cut short by the read waits for its rest. + if (parse_end == end and rest.len > 1 and rest.len < FrameAck.answer.len and + std.mem.startsWith(u8, FrameAck.answer, rest)) break; if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { waited = true; if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; @@ -191,6 +202,63 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } } +/// One frame in flight at most. Each frame the tty draws ends with a device +/// status request (`CSI 5 n`), and no other frame is drawn until the +/// terminal's `CSI 0 n` comes back: over ssh the frames a burst of scrolling +/// or typing would queue behind each other (sshd's window holds megabytes) +/// are never drawn, and the burst ends one frame and one round trip after +/// its last input. On a local terminal the answer is back in well under a +/// millisecond. A terminal that never answers turns this off for good after +/// `timeout_ns`. +const FrameAck = struct { + const request = "\x1b[5n"; + const answer = "\x1b[0n"; + const timeout_ns: u64 = std.time.ns_per_s; + var waiting = std.atomic.Value(bool).init(false); + var off = std.atomic.Value(bool).init(false); + /// When the request went out (the loop's thread only). + var sent_ns: u64 = 0; + + /// Take every answer out of `bytes`, closing up the rest; the new + /// length. The loop is woken for the frame that waited on one. + fn take(bytes: []u8, loop: *Loop) usize { + var len = bytes.len; + var found = false; + while (std.mem.indexOf(u8, bytes[0..len], answer)) |at| { + std.mem.copyForwards(u8, bytes[at..], bytes[at + answer.len .. len]); + len -= answer.len; + found = true; + } + if (found and waiting.swap(false, .acq_rel)) loop.postEvent(.nop) catch {}; + return len; + } + + /// Whether a new frame must wait (and the loop has a wake at the + /// timeout); past the timeout the terminal is taken to never answer. + fn holds(now_ns: u64, io: std.Io) bool { + if (off.load(.acquire) or !waiting.load(.acquire)) return false; + const waited_ns = now_ns -| sent_ns; + if (waited_ns >= timeout_ns) { + off.store(true, .release); + waiting.store(false, .release); + return false; + } + requestWake(io, @intCast((timeout_ns - waited_ns + std.time.ns_per_ms - 1) / std.time.ns_per_ms)); + return true; + } + + /// After a frame that wrote something: ask for the answer. + fn ask(w: *std.Io.Writer, now_ns: u64) void { + if (off.load(.acquire)) return; + // Waiting before the request goes: a local terminal can answer + // before the write returns, and an answer nobody waited for is lost. + sent_ns = now_ns; + waiting.store(true, .release); + w.writeAll(request) catch return waiting.store(false, .release); + w.flush() catch return waiting.store(false, .release); + } +}; + /// Whether more input is readable within `ms`, polled in short slices so /// that a cancel of the input thread is still seen. A test reader answers /// for itself. @@ -521,6 +589,98 @@ test "an ESC read by itself is the Escape key at once" { vx.caps.kitty_keyboard = false; } +test "a frame's answer is taken wherever the reads cut it, and never typed" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: [2][]const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + while (self.next < self.parts.len) { + const part = self.parts[self.next]; + self.next += 1; + if (part.len == 0) continue; + @memcpy(buf[0..part.len], part); + return part.len; + } + return 0; + } + }; + const all = "x" ++ FrameAck.answer ++ "y"; + defer FrameAck.waiting.store(false, .release); + for (0..all.len) |cut| { + FrameAck.waiting.store(true, .release); + var reader: Reader = .{ .parts = .{ all[0..cut], all[cut..] } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(!FrameAck.waiting.load(.acquire)); + var typed: std.ArrayList(u8) = .empty; + defer typed.deinit(gpa); + var woke = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => {}, + .nop => woke = true, + .key_press => |key| try typed.appendSlice(gpa, key.text orelse return error.UnexpectedInputKey), + else => return error.UnexpectedInputEvent, + }; + std.testing.expectEqualStrings("xy", typed.items) catch |err| { + std.debug.print("cut at {d}\n", .{cut}); + return err; + }; + try std.testing.expect(woke); + } + // An Escape pressed just before an answer, in the same read, is the + // Escape key, not Alt on the answer. + FrameAck.waiting.store(true, .release); + var glued: Reader = .{ .parts = .{ "\x1b" ++ FrameAck.answer, "j" } }; + try readInput(&loop, &glued, &cache); + try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); + try std.testing.expectEqual(.nop, std.meta.activeTag((try loop.tryEvent()).?)); + const escape = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); + try std.testing.expect(!escape.mods.alt); + try std.testing.expectEqual(@as(u21, 'j'), (try loop.tryEvent()).?.key_press.codepoint); + try std.testing.expect(try loop.tryEvent() == null); +} + +test "a terminal that never answers a frame stops being asked after one timeout" { + const io = std.testing.io; + defer { + FrameAck.off.store(false, .release); + FrameAck.waiting.store(false, .release); + FrameAck.sent_ns = 0; + } + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + const t0: u64 = 5 * std.time.ns_per_s; + FrameAck.ask(&out.writer, t0); + try std.testing.expectEqualStrings(FrameAck.request, out.written()); + // Unanswered: the next frame waits, until the timeout. + try std.testing.expect(FrameAck.holds(t0 + 10 * std.time.ns_per_ms, io)); + try std.testing.expect(FrameAck.holds(t0 + 999 * std.time.ns_per_ms, io)); + try std.testing.expect(!FrameAck.holds(t0 + FrameAck.timeout_ns, io)); + // Then never again: no request, no wait. + out.clearRetainingCapacity(); + FrameAck.ask(&out.writer, t0 + 2 * std.time.ns_per_s); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + try std.testing.expect(!FrameAck.holds(t0 + 2 * std.time.ns_per_s, io)); +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -1438,6 +1598,12 @@ const Shell = struct { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); + // The last frame is not answered yet: this one stays owed, and the + // answer's wake draws the state as it is then. + if (FrameAck.holds(host_io.monotonicNs(), s.io)) { + s.core.present_skipped = true; + return; + } _ = s.frame.reset(.retain_capacity); const surface = panel_compositor.compose( s.frame.allocator(), @@ -1523,7 +1689,9 @@ const Shell = struct { } if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); + const before = s.tty.tty_writer.pos + s.tty.writer().end; vx.render(s.tty.writer()) catch {}; + if (s.tty.tty_writer.pos + s.tty.writer().end != before) FrameAck.ask(s.tty.writer(), host_io.monotonicNs()); tz_render.end(); } diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index eaa19b43..9e67c49f 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -64,6 +64,8 @@ pub const Harness = struct { /// when true, print the captured screen state after each pump/waitFor and on /// every assertion, so live test runs can be inspected (zig build test -Dtrace). trace: bool = false, + /// How much of a `CSI 5 n` the output has ended on (`feed`). + status_request: u8 = 0, /// every raw byte the app has emitted (accumulated in pump). Lets tests assert /// on control sequences the emulator consumes and never renders (e.g. OSC 52 /// clipboard writes). Capture is bounded explicitly so a runaway child cannot @@ -147,6 +149,24 @@ pub const Harness = struct { /// Read pty output and feed it to our ghostty terminal for `ms` ms. After /// this, the grid reflects everything the app rendered so far. + /// The app's output into the emulator. The one query answered is the + /// device status request (`CSI 5 n` → `CSI 0 n`), as every terminal + /// does: the tty asks it after each frame and draws no other until the + /// answer (FrameAck in src/tty/tty.zig). + fn feed(self: *Harness, bytes: []const u8) void { + self.stream.nextSlice(bytes); + const request = "\x1b[5n"; + for (bytes) |b| { + if (b == request[self.status_request]) { + self.status_request += 1; + } else self.status_request = if (b == 0x1b) 1 else 0; + if (self.status_request == request.len) { + self.status_request = 0; + _ = libc.write(self.master, "\x1b[0n", 4); + } + } + } + pub fn pump(self: *Harness, ms: i64) !void { const deadline = nowMs() + ms; var buf: [4096]u8 = undefined; @@ -157,7 +177,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); } } if (self.trace) self.traceScreen("pump"); @@ -176,7 +196,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch return false; if (n == 0) return false; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); return true; } @@ -243,7 +263,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); } const text = try self.screenText(); defer self.gpa.free(text); @@ -270,7 +290,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true; } return false; -- cgit v1.3 From 290015c17de9348d10273cac1495950cc3e92d3a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 19:47:39 -0300 Subject: The 9P monkey allows a look's misses to each Twrite that succeeded, not to none when one failed A look line written a byte a Twrite is a look a byte, each its own write logging its own miss; when its last byte was a control character that one write failed, and the oracle dropped every miss allowance on the path, so seed 51 failed with 21 errs for 1 refused write. Allowances are now counted over the Twrites that succeeded (and an hwrite's only once it has). Co-Authored-By: Claude Opus 5.5 --- test/monkey9p.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) (limited to 'test') diff --git a/test/monkey9p.py b/test/monkey9p.py index c0e99fb8..ee0b0ebc 100644 --- a/test/monkey9p.py +++ b/test/monkey9p.py @@ -641,11 +641,11 @@ def op_write(sess, op): ok, body = w.call(TOPEN, struct.pack(' Date: Tue, 29 Sep 2026 20:03:34 -0300 Subject: The GUI monkey lives in test/ and runs as zig build monkey-gui It was a scratch tool (.scratch/render/tools/gui_monkey.py) that the gates rely on and that imported fs.py from a workspace since removed. It is test/gui_monkey.py now, importing from its own directory, and zig build monkey-gui -Dplatform=gui runs it on the built window, the same hidden test windows; it exits 1 when a combination fails, and its outdir is optional. Co-Authored-By: Claude Opus 5.5 --- build.zig | 12 ++++ test/gui_monkey.py | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/gui_monkey.py (limited to 'test') diff --git a/build.zig b/build.zig index e9e64056..f6081dad 100644 --- a/build.zig +++ b/build.zig @@ -1580,6 +1580,18 @@ pub fn build(b: *std.Build) void { run_monkey9p.setCwd(b.path(".")); run_monkey9p.has_side_effects = true; b.step("monkey-9p", "random 9P operations checking the documented rules (-- [--seed N]... [--steps M] [--replay F] [--shrink F] [--no-shrink])").dependOn(&run_monkey9p.step); + // The GUI's effects under random input in a hidden test window, each + // Motion x Lift x Bloom (test/gui_monkey.py): a gate, not part of + // fs-test, since it needs a Wayland display. + const monkey_gui_step = b.step("monkey-gui", "random input to hidden GUI test windows across the effect settings (-Dplatform=gui; -- [--seed N] [--steps M])"); + if (platform == .gui) { + const run_monkey_gui = b.addSystemCommand(&.{ "python3", "-B", "test/gui_monkey.py" }); + run_monkey_gui.addArtifactArg(exe); + if (b.args) |args| run_monkey_gui.addArgs(args); + run_monkey_gui.setCwd(b.path(".")); + run_monkey_gui.has_side_effects = true; + monkey_gui_step.dependOn(&run_monkey_gui.step); + } else monkey_gui_step.dependOn(&b.addFail("monkey-gui needs -Dplatform=gui").step); const run_monkey9p_smoke = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" }); run_monkey9p_smoke.addArtifactArg(exe); run_monkey9p_smoke.addArg("--smoke"); diff --git a/test/gui_monkey.py b/test/gui_monkey.py new file mode 100644 index 00000000..2b32f050 --- /dev/null +++ b/test/gui_monkey.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden +test window gets a random burst of clicks, drags, wheels, keys, new and +joined columns, opened files and theme changes, then must settle (its +frames stop changing) with no crash record and its process alive. + +gui_monkey.py [outdir] [--seed N] [--steps N]; `zig build +monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built +window. Exits 1 when any combination failed; what failed is in outdir. +""" +import argparse +import hashlib +import itertools +import os +import random +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fs import session # noqa: E402 + +MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful'] +LIFTS = ['off', 'shadow', 'rim', 'auto'] +BLOOMS = ['0', '2'] +THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak'] +W, H = 1280, 840 + + +def settled(cap, timeout=20): + latest = cap / 'latest.ppm' + previous = None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + time.sleep(.4) + try: + data = latest.read_bytes() + except FileNotFoundError: + continue + digest = hashlib.sha256(data).hexdigest() + if digest == previous: + return True + previous = digest + return False + + +def run(binary, out, motion, lift, bloom, seed, steps): + rng = random.Random(seed) + root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-')) + cap = root / 'cap' + cap.mkdir() + (root / 'config/pardes').mkdir(parents=True) + (root / 'config/pardes/init').write_text( + f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n') + src = root / 'a.zig' + src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200))) + for name in ('b.txt', 'c.txt'): + (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80))) + display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0') + env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0', + 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} + name = 'monkey' + launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)] + handles = {} + verdict = 'ok' + log = [] + try: + with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _): + fd = handles['input_fd'] + time.sleep(1.0) + + def mouse(kind, button, x, y): + os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode()) + + def first(): + rows = client.read('/index').decode().splitlines() + return int(rows[0].split()[0]) if rows else None + + for _ in range(steps): + x, y = rng.randrange(W), rng.randrange(H) + action = rng.randrange(12) + try: + if action in (0, 1): + mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}') + elif action == 2: + x2, y2 = rng.randrange(W), rng.randrange(H) + mouse('down', 1, x, y) + for i in range(1, 6): + mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5) + mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}') + elif action == 3: + amount = rng.choice([-6, -2, 2, 6]) + os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}') + elif action == 4: + mouse('motion', 0, x, y); log.append(f'hover {x},{y}') + elif action == 5: + client.write('/ctl', b'Newcol\n'); log.append('Newcol') + elif action == 6: + serial = first() + if serial is not None: + target = root / rng.choice(['b.txt', 'c.txt', 'a.zig']) + client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}') + elif action == 7: + serial = first() + if serial is not None: + # Delcol too: the last column's going leaves an + # empty window now (acme's), not a quit. + word = rng.choice(['Joincol', 'Collapse', 'Delcol']) + try: + client.write(f'/pane/{serial}/exec', (word + '\n').encode()) + except OSError: + pass + log.append(word) + else: + # An empty window: pane/new makes its column and a pane. + try: + client.read('/pane/new') + except OSError: + pass + log.append('pane/new (empty window)') + elif action == 8: + os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b'])) + log.append('keys') + elif action == 9: + theme = rng.choice(THEMES) + client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}') + elif action == 10: + mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}') + else: + time.sleep(rng.choice([0, .05, .2])); log.append('pause') + except OSError as why: + log.append(f'refused: {why}') + time.sleep(.03) + if not settled(cap): + verdict = 'never settled' + try: + os.kill(handles['pid'], 0) + except OSError: + verdict = 'process gone' + client.read('/index') + except Exception as why: # noqa: BLE001 + verdict = f'failed: {type(why).__name__}: {str(why)[:300]}' + crashes = list((root / 'config').rglob('*crash*')) + if crashes: + verdict = 'crash record: ' + ', '.join(str(c) for c in crashes) + label = f'{motion}-{lift}-bloom{bloom}' + if verdict != 'ok': + (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n') + for c in crashes: + shutil.copy(c, out / f'{label}-{c.name}') + if (cap / 'latest.ppm').exists(): + shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm') + shutil.rmtree(root, ignore_errors=True) + return label, verdict + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('binary', type=Path) + ap.add_argument('out', type=Path, nargs='?') + ap.add_argument('--seed', type=int, default=1) + ap.add_argument('--steps', type=int, default=60) + args = ap.parse_args() + if args.out is None: + args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-')) + args.out.mkdir(parents=True, exist_ok=True) + bad = 0 + for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)): + label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps) + print(f'{label}: {verdict}', flush=True) + bad += verdict != 'ok' + print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True) + sys.exit(1 if bad else 0) + + +if __name__ == '__main__': + main() -- cgit v1.3