diff options
Diffstat (limited to 'tools/bench_main.zig')
| -rw-r--r-- | tools/bench_main.zig | 158 |
1 files changed, 151 insertions, 7 deletions
diff --git a/tools/bench_main.zig b/tools/bench_main.zig index fbaea8a..6e453a8 100644 --- a/tools/bench_main.zig +++ b/tools/bench_main.zig @@ -30,6 +30,8 @@ const Mode = enum { editor, /// A named experiment: one controlled variable, many trials, machine-readable. sweep, + /// Behaviour rather than speed: things that were broken on this transport and must stay fixed. + check, }; /// Which variable an experiment varies. One per run, because the point is a controlled variable and @@ -100,6 +102,10 @@ pub fn main(init: std.process.Init.Minimal) void { o.mode = .editor; continue; } + if (eql(a, "--check")) { + o.mode = .check; + continue; + } if (eql(a, "--csv")) { o.csv = true; continue; @@ -258,6 +264,7 @@ fn run(o: Options) !void { .link => try link(&port, o, &r), .editor => try editor(&port, o, &r), .sweep => try sweep(&port, o, &r), + .check => try check(&port, o, &r), } } @@ -400,7 +407,7 @@ fn trials(port: *serial.Port, o: Options, r: *Report, c: Condition) !void { last_bytes = v.bytes; last_settle = v.settle_us; if (o.csv) r.print("{s},{s},{d},{d},{d},{s},{d},{d},{d},{d}\n", .{ - o.label, @tagName(o.which), c.cols, c.rows, c.length, c.op, + o.label, @tagName(o.which), c.cols, c.rows, c.length, c.op, rep, pos(v.rtt_us), pos(v.settle_us), v.bytes, }); } else lost += 1; @@ -413,7 +420,7 @@ fn trials(port: *serial.Port, o: Options, r: *Report, c: Condition) !void { } std.mem.sort(i64, rtts[0..got], {}, std.sort.asc(i64)); r.print(" {s:<12} {d:>3}x{d:<3} len {d:>4} median {d:>7} us spread {d:>6} us {d:>5} B\n", .{ - c.op, c.cols, c.rows, c.length, + c.op, c.cols, c.rows, c.length, pos(rtts[got / 2]), pos(rtts[got - 1] - rtts[0]), last_bytes, }); r.flush(); @@ -486,7 +493,7 @@ fn link(port: *serial.Port, o: Options, r: *Report) !void { const up_ok = up_stat.bytes == sent and up_stat.crc == want_crc; r.print(" uplink host -> board\n", .{}); r.print(" {d} B in {d} us = {d} B/s ({d}% of wire)\n", .{ - sent, up_us, @divTrunc(@as(i64, sent) * 1_000_000, up_us), + sent, up_us, @divTrunc(@as(i64, sent) * 1_000_000, up_us), @divTrunc(@as(i64, sent) * 1_000_000 * 100, up_us * @as(i64, port.capacity())), }); if (up_ok) { @@ -529,7 +536,7 @@ fn link(port: *serial.Port, o: Options, r: *Report) !void { r.print("\n downlink board -> host\n", .{}); r.print(" {d} B in {d} us = {d} B/s ({d}% of wire)\n", .{ - got, down_us, @divTrunc(@as(i64, got) * 1_000_000, down_us), + got, down_us, @divTrunc(@as(i64, got) * 1_000_000, down_us), @divTrunc(@as(i64, got) * 1_000_000 * 100, down_us * @as(i64, port.capacity())), }); if (down_stat) |s| { @@ -618,8 +625,8 @@ fn editor(port: *serial.Port, o: Options, r: *Report) !void { const pass = s.lost == 0 and s.median_us >= 0 and s.median_us <= ceiling; if (pass) best = rate; r.print(" {d:>7} {d:>9} us {d:>9} us {d:>7} {d:>4}% {d:>5} {s}\n", .{ - pos(rate), pos(s.median_us), pos(s.median_settle_us), - s.median_bytes, s.wire_percent, s.lost, + pos(rate), pos(s.median_us), pos(s.median_settle_us), + s.median_bytes, s.wire_percent, s.lost, if (s.lost > 0) "LOST INPUT" else if (pass) "ok" else "behind", }); r.flush(); @@ -704,7 +711,7 @@ fn usage() void { out( \\p4-bench - measure the board's serial link, verified with a checksum \\ - \\ p4-bench [--link | --editor] [--port <path>] [--baud <rate>] + \\ p4-bench [--link | --editor | --check] [--port <path>] [--baud <rate>] \\ [--bulk <bytes>] [--samples <n>] [--no-reset] \\ \\ --link (default) bulk throughput each way plus round-trip latency, every byte @@ -735,3 +742,140 @@ fn fatal(msg: []const u8) noreturn { fn eql(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b); } + +// -------------------------------------------------------------------------------- behaviour checks +// +// Three things were broken on this transport in ways no latency number could show, and each is +// checked here because each was found by hand and would otherwise be found by hand again. + +/// Read until the wire has been quiet for `quiet_ms`, or `cap_ms` has passed, and return what +/// arrived. "Quiet" rather than "for a fixed time" because a burst of six hundred characters is many +/// frames and an unknown number of milliseconds; a reply read before those have finished is the +/// previous action's output, which is how the first version of these checks fooled itself. +fn soak(port: *serial.Port, buf: []u8, quiet_ms: i64, cap_ms: i64) []u8 { + var n: usize = 0; + const cap = port.nowMs() + cap_ms; + var last = port.nowMs(); + while (port.nowMs() < cap and n < buf.len) { + const got = port.readTimeout(buf[n..], 20) catch 0; + if (got > 0) { + n += got; + last = port.nowMs(); + } else if (port.nowMs() - last >= quiet_ms) break; + } + return buf[0..n]; +} + +/// The last absolute cursor position in `bytes`, as 1-based (row, col). +/// +/// The board's frames end by positioning the cursor and then padding with repeats of that same +/// sequence, so the final CUP in a reply is where the editor put the cursor. That makes the cursor +/// an observable: it is how these checks see what the editor did without reconstructing a screen. +const Cursor = struct { row: u32, col: u32 }; + +fn lastCup(bytes: []const u8) ?Cursor { + var found: ?Cursor = null; + var i: usize = 0; + while (i + 3 < bytes.len) : (i += 1) { + if (bytes[i] != 0x1b or bytes[i + 1] != '[') continue; + var j = i + 2; + var row: u32 = 0; + var col: u32 = 0; + var digits: usize = 0; + while (j < bytes.len and bytes[j] >= '0' and bytes[j] <= '9') : (j += 1) { + row = row * 10 + (bytes[j] - '0'); + digits += 1; + } + if (digits == 0 or j >= bytes.len or bytes[j] != ';') continue; + j += 1; + digits = 0; + while (j < bytes.len and bytes[j] >= '0' and bytes[j] <= '9') : (j += 1) { + col = col * 10 + (bytes[j] - '0'); + digits += 1; + } + if (digits == 0 or j >= bytes.len or bytes[j] != 'H') continue; + found = .{ .row = row, .col = col }; + i = j; + } + return found; +} + +fn check(port: *serial.Port, o: Options, r: *Report) !void { + try ready(port, o); + var buf: [8192]u8 = undefined; + var failures: u32 = 0; + + // 1. A LONE ESCAPE IS STILL THE ESCAPE KEY. + // + // The shell holds a solitary ESC for a few milliseconds because on this wire the first byte of + // every escape sequence arrives alone. The hold must expire, or Escape stops working and the + // editor is unusable. Typing `abc`, pressing Escape, then `x` deletes a character in normal + // mode; if Escape had been swallowed, the `x` would be inserted instead and show up on the wire. + try port.write("abc"); + _ = soak(port, &buf, 150, 1500); + try port.write("\x1b"); + _ = soak(port, &buf, 150, 1500); + try port.write("x"); + const after_x = soak(port, &buf, 200, 1500); + const escaped = std.mem.indexOfScalar(u8, after_x, 'x') == null; + r.print(" lone Escape still leaves insert mode {s}\n", .{if (escaped) "ok" else "FAILED"}); + if (!escaped) failures += 1; + + // 2. AN ESCAPE SEQUENCE SPLIT ACROSS READS PARSES AS ONE EVENT. + // + // This is the bug that made the mouse look unimplemented. At 115200 the bytes of a report are + // 87 us apart, so the board reads them one at a time; a parser that resolves a lone ESC turns + // one click into ten key presses, and the `0` among them is "go to column zero" in normal mode, + // which is exactly where the cursor kept landing. + // + // The line has to be long enough to contain the clicked column, or the editor correctly clamps + // to the end of the line and the check measures the clamp instead of the parse. + try port.write("\x1b"); + _ = soak(port, &buf, 150, 1500); + try port.write("dd"); + _ = soak(port, &buf, 200, 2000); + try port.write("i"); + _ = soak(port, &buf, 150, 1500); + try port.write("abcdefghijklmnopqrstuvwxyz0123"); + _ = soak(port, &buf, 250, 3000); + try port.write("\x1b"); + _ = soak(port, &buf, 200, 2000); + + const want_col: u32 = 18; + var report: [16]u8 = undefined; + const click = std.fmt.bufPrint(&report, "\x1b[<0;{d};3M", .{want_col}) catch unreachable; + // NO gap between the bytes. The wire already provides one - 87 us - and anything longer than the + // shell's hold would expire it, which is the check defeating itself rather than exercising the + // fix. One write per byte is what stops them arriving as a single read. + for (click) |b| try port.write(&[_]u8{b}); + _ = soak(port, &buf, 250, 2000); + // The RELEASE is what commits it. A press alone paints the new position and then reverts, because + // a press is the start of a drag and the caret does not move until the gesture ends - so a check + // that reads the cursor after the press alone measures the revert and calls a working click + // broken. This cost an hour. + const up = std.fmt.bufPrint(&report, "\x1b[<0;{d};3m", .{want_col}) catch unreachable; + for (up) |b| try port.write(&[_]u8{b}); + const reply = soak(port, &buf, 250, 2000); + const landed = lastCup(reply); + const at_col = landed != null and landed.?.col == want_col; + if (landed) |at| { + r.print(" a click split byte-by-byte lands at {d} {s} (cursor {d},{d})\n", .{ + want_col, if (at_col) "ok" else "FAILED", at.row, at.col, + }); + } else r.print(" a click split byte-by-byte lands at {d} FAILED (no reply)\n", .{want_col}); + if (!at_col) failures += 1; + + // A BURST IS NOT CHECKED HERE, deliberately. The bug it would cover - input lost while the + // transmitter was full - has a deterministic host test in `src/pardes/input_rescue.zig` that + // loses 67 bytes with the rescue removed and needs no board at all. Every hardware oracle for it + // that was tried here was worse than that: the cursor stops being reported past 160 characters + // because the wrapped line outgrows the viewport, and a screen reconstruction cannot be rebuilt + // mid-session because the board only ever sends what CHANGED. A check that cannot fail honestly + // is worse than no check, so this file keeps only the two things hardware alone can answer. + + r.print("\n {d} failure(s)\n", .{failures}); + // Flushed here, not by the caller: these lines ARE the result, and returning an error would + // otherwise discard the very output that says which check failed. + r.flush(); + if (failures > 0) return error.CheckFailed; +} |
