summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/fs.py90
-rw-r--r--test/pdf.zig168
-rw-r--r--test/pdf_scroll_bench.zig106
-rw-r--r--test/selfmount.py10
-rw-r--r--test/snapshots/nested-optout.golden60
5 files changed, 386 insertions, 48 deletions
diff --git a/test/fs.py b/test/fs.py
index 2782407d..282cca8b 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -479,6 +479,22 @@ def run_file(binary):
assert run(client, term, b'fi\n') == b'error not run\n' # a syntax error runs nothing
assert run(client, term, b"echo 'open\n") == b'error not run\n' # nor does an open quote
assert run(client, term, b'true\n') == b'exit 0\n' # ...and the shell is clear after
+ # A paging command run through pty/run does not wait in less: the
+ # terminal's PAGER is `pardes -`, and its text is a +Pager pane.
+ if shutil.which('git'):
+ repo = root / 'pagedrepo'
+ repo.mkdir()
+ git = ['git', '-C', str(repo), '-c', 'user.name=p', '-c', 'user.email=p@x']
+ subprocess.run(git + ['init', '-q'], check=True, env=dict(os.environ, HOME=str(root)))
+ (repo / 'f').write_text('x\n')
+ subprocess.run(git + ['add', 'f'], check=True, env=dict(os.environ, HOME=str(root)))
+ subprocess.run(git + ['commit', '-qm', 'paged-commit-subject'], check=True, env=dict(os.environ, HOME=str(root)))
+ assert run(client, term, b'git -C pagedrepo log\n').startswith(b'exit 0\n')
+ pager = [r.split()[0].decode() for r in client.read('/index').splitlines() if r.split()[3:4] == [f'{repo}/+Pager'.encode()]] # git -C runs it in the repo
+ assert len(pager) == 1, client.read('/index')
+ assert b'paged-commit-subject' in client.read(f'/pane/{pager[0]}/body')
+ assert b'\x1b' not in client.read(f'/pane/{pager[0]}/body')
+ client.remove(f'/pane/{pager[0]}')
# A pty is not given one row (it loses the prompt's input mark,
# and the shell would read busy for ever): refused, and said.
try:
@@ -1306,6 +1322,32 @@ def test(binary, quic=False):
# opened is deleted, as an $EDITOR must; one on a file already open
# waits on that pane; a session that goes away ends it with 1.
with session(binary, root, 'wait') as (client, address):
+ # A command run from a pane's tag gets acme's $winid, that pane;
+ # one from the workspace's tag, none.
+ def command_body(node, line):
+ client.write(node, line)
+ serial = int(client.read(node).split()[-1])
+ deadline = time.monotonic() + 10
+ while True:
+ log = client.read('/log').decode()
+ ran = log.rfind(f'\nrun {serial} ')
+ if ran >= 0 and f'\nexit {serial} ' in log[ran:]:
+ body = client.read(f'/pane/{serial}/body').decode().split('% echo winid')[-1]
+ client.remove(f'/pane/{serial}')
+ return body
+ assert time.monotonic() < deadline, log[-400:]
+ time.sleep(.05)
+ first_pane = min(int(row.split()[0]) for row in client.read('/index').splitlines())
+ assert f'winid={first_pane}\n' in command_body(f'/pane/{first_pane}/tagexec', b'echo winid=$winid\n')
+ assert 'winid=\n' in command_body('/tagexec', b'echo winid=$winid\n')
+ # A second --detach of a name a live session holds: said, exit 1,
+ # no Zig error trace.
+ second_env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
+ second_env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root))
+ second = subprocess.run([binary, '--detach=wait'], cwd=root, env=second_env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=20)
+ assert second.returncode == 1, second
+ assert second.stderr == b'pardes: a session named wait is already running\n', second.stderr
status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines())
env = {k: v for k, v in os.environ.items() if k != 'PARDES_FORWARD_LOOK'}
env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid'])
@@ -1395,6 +1437,30 @@ def test(binary, quic=False):
# A pane address the session has not is refused in words, exit
# 1, no pane made for a file of that name; one it has is looked
# at. A name with a newline is said in words, not an error name.
+ # `pardes -` (a terminal's PAGER): stdin, its escapes stripped, in a
+ # +Pager pane of the session, clean, and back at once.
+ fed = subprocess.run([binary, '-'], cwd=root, env=env, input=b'\x1b[1mbold\x1b[0m text\n',
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
+ assert fed.returncode == 0, fed.stderr
+ made_now, = serials() - before
+ assert client.read(f'/pane/{made_now}/name') == f'{root}/+Pager\n'.encode()
+ assert client.read(f'/pane/{made_now}/body') == b'bold text\n'
+ row = [r for r in client.read('/index').splitlines() if r.split()[0] == str(made_now).encode()][0]
+ assert row.split()[2] == b'0', row # clean: nothing to ask at Exit
+ client.remove(f'/pane/{made_now}')
+ # A new name in a directory there but not writable: refused now,
+ # not a pane that only fails at Save.
+ shut = root / 'shut-dir'
+ shut.mkdir()
+ shut.chmod(0o500)
+ try:
+ refused = subprocess.run([binary, 'shut-dir/new.txt'], cwd=root, env=env,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
+ assert refused.returncode == 1, refused
+ assert b'may not be read or written' in refused.stderr, refused.stderr
+ assert serials() == before, (serials(), before)
+ finally:
+ shut.chmod(0o755)
for word, said in (('@p999:1', b'pardes: @p999:1: this session has no such pane'),
('two\nlines.txt', b'a file name is one line')):
refused = subprocess.run([binary, word], cwd=root, env=env,
@@ -1411,6 +1477,30 @@ def test(binary, quic=False):
assert orphan.poll() is None
assert orphan.wait(timeout=5) == 1
+ # `pardes -` outside a session: an editor whose first pane is a
+ # +Pager of stdin, as `vim -`; here a detached one, to read it.
+ outside_env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
+ outside_env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), PARDES_NOTIME='1')
+ boot = subprocess.Popen([binary, '--detach=stdinboot', '-'], cwd=root, env=outside_env,
+ stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ start_new_session=True)
+ try:
+ boot.stdin.write(b'\x1b[32mfrom stdin\x1b[m\nsecond\n')
+ boot.stdin.close()
+ sock = root / 'pardes-9p-stdinboot.sock'
+ deadline = time.monotonic() + 10
+ while not sock.exists():
+ assert boot.poll() is None, boot.stderr.read()
+ assert time.monotonic() < deadline, 'the stdin boot never listened'
+ time.sleep(.05)
+ with Client(str(sock)) as booted:
+ first = booted.read('/index').split(b'\n')[0].split()
+ assert first[3] == f'{root}/+Pager'.encode(), first
+ assert booted.read(f'/pane/{first[0].decode()}/body') == b'from stdin\nsecond\n'
+ finally:
+ boot.terminate()
+ boot.wait(timeout=10)
+
# Killed with SIGTERM or SIGHUP, a tty editor puts its terminal
# back (main screen, cooked mode) before it dies: a nested one
# killed in a pane leaves that pane usable.
diff --git a/test/pdf.zig b/test/pdf.zig
index fe084cc1..6864f247 100644
--- a/test/pdf.zig
+++ b/test/pdf.zig
@@ -730,6 +730,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
const request = panes.Pdf.renderRequest(
panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport,
pdf_raster_policy,
+ pane.pdf.?.fit,
);
try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi);
try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension);
@@ -1119,6 +1120,171 @@ test "a fling's banded pages show the reader exactly what whole pages would" {
}
}
+/// MuPDF clips the edges of a path to the pixmap, and an edge that crosses a
+/// band's clip is stepped from a different start: along its whole length in
+/// the band, its antialiased pixels can be a level or two off the whole
+/// page's. Never more.
+const band_tolerance: u8 = 4;
+
+fn expectNearRows(expected: []const u8, got: []const u8) !void {
+ try std.testing.expectEqual(expected.len, got.len);
+ var worst: u8 = 0;
+ for (expected, got) |a, b| worst = @max(worst, if (a > b) a - b else b - a);
+ if (worst > band_tolerance) {
+ std.debug.print("band rows are {d} levels off the whole page's\n", .{worst});
+ return error.TestExpectedEqual;
+ }
+}
+
+test "a tall page is drawn at the display's resolution, in bands that are the whole page's rows" {
+ if (!pdf_enabled or platform == .web) return;
+
+ // The page is fifteen A4 heights tall. Squeezed whole into the raster
+ // budget it was a third as wide as the pane and blown up, blurred; it is
+ // drawn instead at the pane's own resolution, a band around the screen at
+ // a time, and every band row must be the row a whole-page render makes
+ // (to `band_tolerance`), and the same row however it was scrolled to.
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const bytes = try pdf_impl.makeTallTestPdf(gpa);
+ defer gpa.free(bytes);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tall.pdf", .data = bytes });
+ var dir_buf: [4096]u8 = undefined;
+ const dir_len = try tmp.dir.realPath(std.testing.io, &dir_buf);
+ const path = try std.fmt.allocPrint(gpa, "{s}/tall.pdf", .{dir_buf[0..dir_len]});
+ defer gpa.free(path);
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 120, .rows = 40 });
+ defer p.deinit();
+ p.native_images = true;
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+
+ const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport;
+ const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy, pv.fit);
+ const shape = try pv.document.measureRenderAt(0, request);
+ if (pdf_raster_policy.match_viewport) {
+ // The rendered scale IS the screen's: one raster pixel per pixel of
+ // the pane's width, so nothing is scaled up.
+ try std.testing.expectEqual(@as(usize, viewport.pixel_w), shape.width);
+ } else {
+ // Kitty's policy is a dpi, kept low for the wire; the tall page gets
+ // it as every other page does, not a quarter of it.
+ try std.testing.expectEqual(
+ @as(usize, @intFromFloat(pdf_impl.tall_test_page.width)) * pdf_raster_policy.dpi / 72,
+ shape.width,
+ );
+ }
+ try std.testing.expect(panes.Pdf.isBanded(shape));
+
+ // The reference: the whole page in one render, before highlights and
+ // tint, and tinted as the pane tints.
+ const plain = try gpa.alloc(u8, shape.len);
+ defer gpa.free(plain);
+ try pv.document.renderIntoAt(0, request, shape, shape.wholePage(), &.{}, plain);
+ const whole = try gpa.dupe(u8, plain);
+ defer gpa.free(whole);
+ try pdf_impl.tintRgba(whole, pv.tint, panes.Pdf.tintColors(p));
+
+ const max_scroll = @as(f64, @floatFromInt(pv.document_height - viewport.pixel_h));
+ var middle: []u8 = &.{};
+ defer gpa.free(middle);
+ var middle_y: usize = 0;
+ for ([_]f64{ 0, @round(max_scroll / 2), max_scroll }, 0..) |at, step| {
+ _ = panes.Pdf.scrollPane(p, pane, at - pv.document_scroll_y);
+ pv.scroll_travel = 0; // read, not flung
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 1), surface.nimages);
+ const place = surface.images[0].?;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = panes.Pdf.rasterForPage(pv, 0) orelse return error.MissingPdfRaster;
+ // Bounded: a band, never the page, and within one buffer's budget.
+ try std.testing.expectEqual(shape.width, place.iw);
+ try std.testing.expect(raster.band_h < raster.ih);
+ try std.testing.expect(place.rgba.len <= pdf_impl.max_owned_raster_bytes);
+ // Prefetched: a screenful beyond what is shown, both ways, where the
+ // page goes on.
+ const seen_top = raster.band_y + geometry.src.y;
+ const seen_end = seen_top + geometry.src.h;
+ try std.testing.expect(raster.band_y <= seen_top -| geometry.src.h);
+ try std.testing.expect(raster.band_y + raster.band_h >= @min(raster.ih, seen_end + geometry.src.h));
+ // ...and every row of the band is the whole page's row.
+ const stride = shape.stride;
+ try expectNearRows(whole[raster.band_y * stride ..][0 .. raster.band_h * stride], place.rgba);
+ if (step == 1) {
+ middle = try gpa.dupe(u8, place.rgba);
+ middle_y = raster.band_y;
+ }
+ }
+
+ // Straight back to the middle from the foot: the rows rendered afresh
+ // are the very rows rendered on the way down, so no seam moves with the
+ // path a reader took.
+ _ = panes.Pdf.scrollPane(p, pane, @round(max_scroll / 2) - pv.document_scroll_y);
+ pv.scroll_travel = 0;
+ _ = frame.reset(.retain_capacity);
+ const again = (try p.render(frame.allocator())).images[0].?;
+ const again_y = panes.Pdf.rasterForPage(pv, 0).?.band_y;
+ const stride = shape.stride;
+ const common_top = @max(middle_y, again_y);
+ const common_end = @min(middle_y + middle.len / stride, again_y + again.rgba.len / stride);
+ try std.testing.expect(common_end > common_top);
+ try std.testing.expectEqualSlices(
+ u8,
+ middle[(common_top - middle_y) * stride .. (common_end - middle_y) * stride],
+ again.rgba[(common_top - again_y) * stride .. (common_end - again_y) * stride],
+ );
+ _ = panes.Pdf.scrollPane(p, pane, max_scroll - pv.document_scroll_y);
+ pv.scroll_travel = 0;
+ _ = frame.reset(.retain_capacity);
+ const foot = try gpa.dupe(u8, (try p.render(frame.allocator())).images[0].?.rgba);
+ defer gpa.free(foot);
+
+ // At the foot of the page, a search hit: its highlight lands on exactly
+ // the rows and pixels it would on the whole page, and a pointer over the
+ // word finds the word.
+ try pv.setSearchQuery(gpa, "bottommost");
+ _ = frame.reset(.retain_capacity);
+ const lit = try p.render(frame.allocator());
+ const place = lit.images[0].?;
+ const raster = panes.Pdf.rasterForPage(pv, 0) orelse return error.MissingPdfRaster;
+ const highlights = try panes.Pdf.buildHighlights(pv, frame.allocator(), panes.Pdf.highlightInput(p, 0, pane));
+ const marks = highlights.forPage(0);
+ try std.testing.expect(marks.len > 0);
+ const rows = try pv.document.highlightRows(0, request, marks);
+ try std.testing.expect(rows.start >= raster.band_y and rows.end <= raster.band_y + raster.band_h);
+ // Exactly the highlight's rows changed, and nothing else...
+ try std.testing.expectEqual(foot.len, place.rgba.len);
+ const from = (rows.start - raster.band_y) * stride;
+ const to = (rows.end - raster.band_y) * stride;
+ try std.testing.expectEqualSlices(u8, foot[0..from], place.rgba[0..from]);
+ try std.testing.expectEqualSlices(u8, foot[to..], place.rgba[to..]);
+ try std.testing.expect(!std.mem.eql(u8, foot[from..to], place.rgba[from..to]));
+ // ...to what the whole page painted with them is.
+ const expected = try gpa.dupe(u8, plain[rows.start * stride .. rows.end * stride]);
+ defer gpa.free(expected);
+ try pv.document.paintHighlightsAt(0, request, shape, shape.band(rows.start, rows.end - rows.start), marks, expected);
+ try pdf_impl.tintRgba(expected, pv.tint, panes.Pdf.tintColors(p));
+ try expectNearRows(expected, place.rgba[from..to]);
+
+ const quad = pv.search_results.?.quads[0].quad;
+ const left = @min(quad.ul.x, quad.ll.x);
+ const right = @max(quad.ur.x, quad.lr.x);
+ const top = @min(quad.ul.y, quad.ur.y);
+ const bottom = @max(quad.ll.y, quad.lr.y);
+ const rect = p.rects[0];
+ var inside: usize = 0;
+ for (rect.y..rect.y + rect.h) |row| for (rect.x..rect.x + rect.w) |col| {
+ const point = panes.Pdf.panePointAtPage(p, pane, 0, @intCast(col), @intCast(row), false) orelse continue;
+ if (point.x >= left and point.x <= right and point.y >= top and point.y <= bottom) inside += 1;
+ };
+ try std.testing.expect(inside > 0);
+}
+
/// The pixels a backend samples out of one placement: the source rectangle,
/// row by row, at the texture's own stride. Test-only, and the one operation
/// that makes "same picture" mean something when the textures differ in shape.
@@ -1306,7 +1472,7 @@ test "PDF continuous strip renders every intersecting short page" {
const viewport = panes.Pdf.ensurePaneLayout(p, pane).?;
const visible = panes.Pdf.visiblePages(pv, viewport);
try std.testing.expectEqual(pv.page_count, visible.len);
- const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy);
+ const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy, pv.fit);
try std.testing.expect(pv.page_count <= pv.rasters.len);
for (0..pv.page_count) |page| {
const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4);
diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig
index a4511dbe..166fa809 100644
--- a/test/pdf_scroll_bench.zig
+++ b/test/pdf_scroll_bench.zig
@@ -4,6 +4,7 @@
//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --json
//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --warmup 1 --reps 15
//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --path other.pdf
+//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --cell 16x32 a 2x display
//! zig build pdf-scroll-bench -Doptimize=Debug -- --reps 1 the diagnosis
//! zig build pdf-scroll-bench -Dtracy=~/05-genizah/tracy the zones
//!
@@ -62,7 +63,7 @@ const gpa = std.heap.smp_allocator;
// is what the tty shell actually ships.
const bench_cols: u16 = 120;
const bench_rows: u16 = 40;
-const bench_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 };
+const default_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 };
/// config.wheel_rows is one cell per notch, so a notch is `cell_pixels.h`
/// display pixels. 192 notches is ~3072px — between two and three fit-width
@@ -74,7 +75,7 @@ const slow_ticks_per_frame: usize = 1;
/// The control row is a fixed wall of frames rather than a whole-document
/// traversal: one notch per frame over a real document is tens of thousands of
/// frames, and 240 already crosses several page boundaries at reading speed.
-const slow_frames: usize = 240;
+const default_slow_frames: usize = 240;
/// Guard against a pathological document turning one row into a multi-minute
/// run. Deterministic either way: the frame count is derived from layout.
const max_frames: usize = 2048;
@@ -91,6 +92,10 @@ const Config = struct {
/// Override `max_frames` for one run: a 5000-page manual is 2000 frames of
/// travel per round, which is a fine scoreboard and a poor edit-run loop.
frames: ?usize = null,
+ /// One cell in display pixels: 16x32 is the same window on a 2x display.
+ cell: pardes.CellPixels = default_cell_pixels,
+ /// Frames of the one-notch control row.
+ slow_frames: usize = default_slow_frames,
};
/// The bit-exact description of one scrolling run. `visual` is the invariant;
@@ -120,6 +125,9 @@ const Result = struct {
total_ns: u64,
rasterizations: u64,
rasters_retained: u64,
+ /// Most bytes the pane's rasters held after any frame: every slot's
+ /// pixels, its clean copy, and the spare buffers kept for reuse.
+ peak_raster_bytes: u64,
identity: Identity,
};
@@ -174,13 +182,13 @@ pub fn main(init: std.process.Init) !void {
// One untimed probe boot supplies the strip height every plan is sized
// from, so the frame counts below are a property of the document and the
// viewport rather than of the machine.
- const travel = probeTravel(config.path);
+ const travel = probeTravel(config);
const cap = config.frames orelse max_frames;
const results = [_]Result{
- try measure(config, planFor(.fling_down, travel, cap)),
- try measure(config, planFor(.fling_reverse, travel, cap)),
- try measure(config, planFor(.slow_scroll, travel, cap)),
+ try measure(config, planFor(.fling_down, travel, cap, config.slow_frames)),
+ try measure(config, planFor(.fling_reverse, travel, cap, config.slow_frames)),
+ try measure(config, planFor(.slow_scroll, travel, cap, config.slow_frames)),
};
if (config.json)
@@ -207,9 +215,19 @@ fn parseArgs(args: []const []const u8) Config {
} else if (std.mem.eql(u8, args[i], "--frames") and i + 1 < args.len) {
i += 1;
config.frames = parseCount("--frames", args[i]);
+ } else if (std.mem.eql(u8, args[i], "--slow-frames") and i + 1 < args.len) {
+ i += 1;
+ config.slow_frames = parseCount("--slow-frames", args[i]);
+ } else if (std.mem.eql(u8, args[i], "--cell") and i + 1 < args.len) {
+ i += 1;
+ var it = std.mem.splitScalar(u8, args[i], 'x');
+ const w = parseCount("--cell", it.next() orelse "");
+ const h = parseCount("--cell", it.next() orelse "");
+ if (w > std.math.maxInt(u16) or h > std.math.maxInt(u16)) fatal("--cell {s} is too large", .{args[i]});
+ config.cell = .{ .w = @intCast(w), .h = @intCast(h) };
} else fatal(
"usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N]" ++
- " [--path FILE] [--frames N]",
+ " [--path FILE] [--frames N] [--slow-frames N] [--cell WxH]",
.{},
);
}
@@ -226,28 +244,35 @@ const Travel = struct {
/// same benchmark and the report must not pretend otherwise.
pages: usize,
strip_px: u64,
+ /// The first frame of a fresh pane: opening the document and rasterizing
+ /// what it shows first. One sample, from the probe boot.
+ first_frame_ns: u64,
};
-fn probeTravel(path: []const u8) Travel {
- const core = bootCore(path) catch |err|
+fn probeTravel(config: Config) Travel {
+ const path = config.path;
+ const core = bootCore(config) catch |err|
fatal("cannot boot a PDF pane on {s}: {t}", .{ path, err });
defer core.deinit();
var frame_arena = std.heap.ArenaAllocator.init(gpa);
defer frame_arena.deinit();
+ const started = nowNs();
_ = core.render(frame_arena.allocator()) catch |err|
fatal("first render failed: {t}", .{err});
drainEffects(core);
+ const first_frame_ns = nowNs() -| started;
const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{});
if (pane.pdf == null) fatal("{s} did not open as a PDF", .{path});
const pv = &pane.pdf.?;
- if (pv.page_count < 4)
- fatal("{s} has {d} pages; a fling benchmark needs at least 4", .{ path, pv.page_count });
if (!pv.layout_valid or pv.document_height == 0)
fatal("the pixel strip never laid out; is native_images/cell_pixels wired?", .{});
// Exactly the clamp scrollPdfDocument enforces, so the last fling frame
// lands on the document end instead of adding a no-op frame to the row.
- const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * bench_cell_pixels.h;
- const notch: f64 = @floatFromInt(bench_cell_pixels.h);
+ const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * config.cell.h;
+ // Four screenfuls of strip, in pages or in one tall page.
+ if (pv.document_height < 4 * viewport_h)
+ fatal("{s} is {d}px of strip; a fling benchmark needs at least four screenfuls", .{ path, pv.document_height });
+ const notch: f64 = @floatFromInt(config.cell.h);
if (!std.math.isFinite(notch) or notch <= 0)
fatal("a notch of {d} display pixels cannot size a fling", .{notch});
const strip = @as(f64, @floatFromInt(pv.document_height -| viewport_h));
@@ -258,6 +283,7 @@ fn probeTravel(path: []const u8) Travel {
.per_frame = notch * @as(f64, @floatFromInt(fling_ticks_per_frame)),
.pages = pv.page_count,
.strip_px = pv.document_height,
+ .first_frame_ns = first_frame_ns,
};
}
@@ -267,7 +293,7 @@ fn probeTravel(path: []const u8) Travel {
/// The cap is applied before any arithmetic on the count, so a document the
/// float division cannot describe still produces a runnable plan instead of an
/// overflow.
-fn planFor(scenario: Scenario, travel: Travel, cap: usize) Plan {
+fn planFor(scenario: Scenario, travel: Travel, cap: usize, slow: usize) Plan {
const wanted = travel.max_scroll / travel.per_frame;
const whole: usize = if (std.math.isFinite(wanted) and wanted >= 1)
@intFromFloat(@min(@as(f64, @floatFromInt(cap)), @ceil(wanted)))
@@ -282,15 +308,15 @@ fn planFor(scenario: Scenario, travel: Travel, cap: usize) Plan {
.frames = @max(2, down),
.down_frames = @max(1, down / 2),
},
- .slow_scroll => .{ .scenario = scenario, .frames = slow_frames, .down_frames = slow_frames },
+ .slow_scroll => .{ .scenario = scenario, .frames = slow, .down_frames = slow },
};
}
/// A pane on a real multi-page document with the native pixel-strip path live
/// and a genuine pixel viewport delivered the way a shell delivers one.
-fn bootCore(path: []const u8) !*pardes.Pardes {
+fn bootCore(config: Config) !*pardes.Pardes {
const core = try pardes.Pardes.init(gpa, .{
- .file = path,
+ .file = config.path,
.cols = bench_cols,
.rows = bench_rows,
});
@@ -299,7 +325,7 @@ fn bootCore(path: []const u8) !*pardes.Pardes {
core.update(.{ .resize = .{
.cols = bench_cols,
.rows = bench_rows,
- .cell_pixels = bench_cell_pixels,
+ .cell_pixels = config.cell,
} });
drainEffects(core);
return core;
@@ -318,9 +344,10 @@ fn measure(config: Config, plan: Plan) !Result {
var expected: ?Identity = null;
var rasterizations: u64 = 0;
var rasters_retained: u64 = 0;
+ var peak_raster_bytes: u64 = 0;
for (0..config.warmup + config.reps) |round| {
- const core = try bootCore(config.path);
+ const core = try bootCore(config);
defer core.deinit();
var frame_arena = std.heap.ArenaAllocator.init(gpa);
defer frame_arena.deinit();
@@ -355,6 +382,7 @@ fn measure(config: Config, plan: Plan) !Result {
const elapsed = nowNs() -| started;
total += elapsed;
recorder.observe(frame, surface);
+ peak_raster_bytes = @max(peak_raster_bytes, rasterBytes(pv));
// The same frame boundary src/tty/tty.zig marks, so a Tracy capture
// of this benchmark reads like a capture of the real shell.
pardes.frameMark();
@@ -388,10 +416,19 @@ fn measure(config: Config, plan: Plan) !Result {
.total_ns = totals[totals.len / 2],
.rasterizations = rasterizations,
.rasters_retained = rasters_retained,
+ .peak_raster_bytes = peak_raster_bytes,
.identity = expected.?,
};
}
+/// What the pane's rasters hold right now (untimed: outside the frame).
+fn rasterBytes(pv: anytype) u64 {
+ var total: u64 = 0;
+ for (pv.rasters[0..pv.rasters_len]) |raster| total += raster.rgba.len + raster.clean.len;
+ for (pv.spare[0..pv.spare_len]) |spare| total += spare.len;
+ return total;
+}
+
/// Accumulates the two checksums across one run. Raster generations are
/// rebased on the counter's value at run start so the trace describes the
/// ORDER textures were produced in rather than absolute counter values, which
@@ -545,23 +582,24 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void {
std.debug.print(
"{s}: {d} pages, {d}px strip at {d}x{d} cells of {d}x{d}px\n",
.{
- config.path, travel.pages, travel.strip_px, bench_cols,
- bench_rows, bench_cell_pixels.w, bench_cell_pixels.h,
+ config.path, travel.pages, travel.strip_px, bench_cols,
+ bench_rows, config.cell.w, config.cell.h,
},
);
+ std.debug.print("first frame of a fresh pane: {d}us\n", .{travel.first_frame_ns / 1000});
std.debug.print(
"warmup: {d}, sampled reps: {d}; every number is ONE FRAME (notch batch + render + drain)\n\n",
.{ config.warmup, config.reps },
);
- std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:<16} {s}\n", .{
- "scenario", "frames", "notches", "min",
- "median", "p90", "p99", "max",
- "total_us", "rasters", "kept", "visual",
- "transport",
+ std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:>9} {s:<16} {s}\n", .{
+ "scenario", "frames", "notches", "min",
+ "median", "p90", "p99", "max",
+ "total_us", "rasters", "kept", "peak_kib",
+ "visual", "transport",
});
std.debug.print("{s}\n", .{"-" ** 160});
for (results) |result| std.debug.print(
- "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {x:0>16} {x:0>16}\n",
+ "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {d:>9} {x:0>16} {x:0>16}\n",
.{
result.name,
result.frames,
@@ -574,12 +612,14 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void {
result.total_ns / 1000,
result.rasterizations,
result.rasters_retained,
+ result.peak_raster_bytes / 1024,
result.identity.visual,
result.identity.transport,
},
);
std.debug.print(
- "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end.\n" ++
+ "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end;\n" ++
+ "`peak_kib` is the most the pane's rasters, clean copies and spares held after any frame.\n" ++
"`visual` pins presented pixels and placement and must never change; `transport` traces texture uploads.\n",
.{},
);
@@ -592,16 +632,17 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul
var out: std.Io.Writer = .fixed(&storage);
out.print(
"{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"{s}\",\"path\":\"{s}\"," ++
- "\"pages\":{d},\"strip_px\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[",
+ "\"pages\":{d},\"strip_px\":{d},\"first_frame_ns\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[",
.{
- bench_config.core_optimize, config.path, travel.pages,
- travel.strip_px, config.warmup, config.reps,
+ bench_config.core_optimize, config.path, travel.pages,
+ travel.strip_px, travel.first_frame_ns, config.warmup,
+ config.reps,
},
) catch return;
for (results, 0..) |result, i| out.print(
"{s}{{\"scenario\":\"{s}\",\"frames\":{d},\"notches_per_frame\":{d}," ++
"\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"p99_ns\":{d},\"max_ns\":{d},\"total_ns\":{d}," ++
- "\"rasterizations\":{d},\"rasters_retained\":{d},\"images\":{d}," ++
+ "\"rasterizations\":{d},\"rasters_retained\":{d},\"peak_raster_bytes\":{d},\"images\":{d}," ++
"\"visual\":\"{x:0>16}\",\"transport\":\"{x:0>16}\"}}",
.{
if (i == 0) "" else ",",
@@ -616,6 +657,7 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul
result.total_ns,
result.rasterizations,
result.rasters_retained,
+ result.peak_raster_bytes,
result.identity.images,
result.identity.visual,
result.identity.transport,
diff --git a/test/selfmount.py b/test/selfmount.py
index 86c32fa8..dd59083c 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -176,6 +176,16 @@ def run(binary):
held = run(f"for i in $(seq 40); do cat {mount}/pane/{first}/event > /dev/null & done; "
f"sleep 1; timeout 10 cat {mount}/layout > /dev/null; st=$?; kill $(jobs -p); exit $st\n".encode())
check(held == '0', f'cat layout answers beside 40 held event reads ({held!r})')
+ # fs.md's follow recipe, as written, with plain cat, its output
+ # a pipe (where coreutils cat splices): it hears a record made
+ # after it started.
+ recipe = Path(f'/tmp/{name}.follow')
+ ran = run(f"exec 3<>{mount}/log; echo follow >&3; "
+ f"(sleep 1; echo 'Msg recipe-heard' > {mount}/exec) & "
+ f"timeout 5 cat <&3 | cat > {recipe}; exit 0\n".encode())
+ followed = recipe.read_text() if recipe.exists() else ''
+ recipe.unlink(missing_ok=True)
+ check(ran == '0' and 'recipe-heard' in followed, f'the log follow recipe with cat hears a new record ({followed[-120:]!r})')
client.write('/exec', b'Msg still here\n')
text = ''.join(cell[0] for cell in client.screen()['cells'])
check('still here' in text, 'the editor answers after all of that')
diff --git a/test/snapshots/nested-optout.golden b/test/snapshots/nested-optout.golden
index 1fa7cbe5..3539a16c 100644
--- a/test/snapshots/nested-optout.golden
+++ b/test/snapshots/nested-optout.golden
@@ -1,24 +1,54 @@
== snap optout-file grid=100x31 cursor=9,6
-|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
-|New Tty Find Grep Joincol
-|$ /tmp/pardes-snap/nested-optout/cwd Save Tty Del Togglettymode Filter Collapse
-| Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
-| New Tty Find Grep Joincol
-| /tmp/pardes-snap/nested-optout/cwd/nested.txt Save Tty Del Collapse
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+|$ /tmp/pardes-snap/nested-optout/cwd Tty+bash Save Mode Filter Collapse Del
+| Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/nested-optout/cwd/nested.txt Save Tty Collapse Del
| 1 optout-marker
| 2 second-optout-line
| 3
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/nested-optout/cwd/+New Save Tty Collapse Del
+| 1
== snap inner-shell grid=100x31 cursor=6,6
-|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
-|New Tty Find Grep Joincol
-|$ /tmp/pardes-snap/nested-optout/cwd Save Tty Del Togglettymode Filter Collapse
-| Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
-| New Tty Find Grep Joincol
-| $ /tmp/pardes-snap/nested-optout/cwd/innerdir Save Tty Del Togglettymode Filter Collapse
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+|$ /tmp/pardes-snap/nested-optout/cwd Tty+bash Save Mode Filter Collapse Del
+| Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+| $ /tmp/pardes-snap/nested-optout/cwd/innerdir Tty+bash Save Mode Filter Collapse Del
| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/nested-optout/cwd/innerdir/+New Save Tty Collapse Del
+| 1
+|
+|
+|
+| /tmp/pardes-snap/nested-optout/cwd/+New Save Tty Collapse Del
+| 1
== snap inner-launch grid=100x31 cursor=11,9
-|6: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
-|7: New Tty Find Grep Joincol
-|8: /tmp/pardes-snap/nested-optout/cwd/innerdir/deeper.txt Save Tty Del Collapse
+|6: Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+|7: New Tty Find Grep Joincol Delcol
+|8: /tmp/pardes-snap/nested-optout/cwd/innerdir/deeper.txt Save Tty Collapse Del
|9: 1 deepest-marker
|10: 2