From 9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 10:41:33 -0300 Subject: replace ArrayLists with bounded storage --- test/e2e_harness.zig | 50 ++++-- test/hxdiff.zig | 163 ++++++++++++------- test/image_harness.zig | 137 +++++++++------- test/lspbench.zig | 29 ++-- test/pdf_bench.zig | 29 ++-- test/pdf_sections_bench.zig | 61 ++++---- test/perf.zig | 94 ++++++----- test/snapshot.zig | 372 +++++++++++++++++++++++++++++++++----------- 8 files changed, 633 insertions(+), 302 deletions(-) (limited to 'test') diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index eb4c7a2f..c188827e 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -22,6 +22,24 @@ pub fn nowMs() i64 { _ = libc.clock_gettime(.MONOTONIC, &ts); return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); } +const raw_capture_max = 64 * 1024 * 1024; + +pub const RawCapture = struct { + bytes: [raw_capture_max]u8 = undefined, + items: []u8 = &.{}, + full: bool = false, + + fn appendSlice(self: *RawCapture, chunk: []const u8) error{CaptureFull}!void { + if (self.full) return error.CaptureFull; + if (chunk.len > self.bytes.len - self.items.len) { + self.full = true; + return error.CaptureFull; + } + const end = self.items.len + chunk.len; + @memcpy(self.bytes[self.items.len..end], chunk); + self.items = self.bytes[0..end]; + } +}; /// E2E test harness: forks the app in a pty and parses its output with a /// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString), @@ -47,8 +65,9 @@ pub const Harness = struct { trace: bool = false, /// 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). gpa-owned; freed in deinit. - raw: std.ArrayList(u8) = .empty, + /// clipboard writes). Capture is bounded explicitly so a runaway child cannot + /// consume unbounded test-runner memory. + raw: *RawCapture, pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness { return initArgs(gpa, exe, rows, cols, null); @@ -63,14 +82,16 @@ pub const Harness = struct { /// Like init, but passes extra CLI args (e.g. "-l", "dump.zon"). pub fn initArgv(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, args: []const ?[*:0]const u8) !Harness { + const argv = try gpa.allocSentinel(?[*:0]const u8, args.len + 1, null); + defer gpa.free(argv); + argv[0] = exe; + for (args, 0..) |a, i| argv[i + 1] = a; + var master: c_int = undefined; const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { - var argv: [8:null]?[*:0]const u8 = @splat(null); - argv[0] = exe; - for (args, 0..) |a, i| argv[i + 1] = a; - _ = execvp(exe, &argv); + _ = execvp(exe, argv.ptr); _exit(127); } const term = try gpa.create(ghostty_vt.Terminal); @@ -84,6 +105,9 @@ pub const Harness = struct { // vtStream() captures a pointer to `term`; term is heap-allocated so the // pointer stays valid for the life of the harness. const stream = term.vtStream(); + const raw = try gpa.create(RawCapture); + errdefer gpa.destroy(raw); + raw.* = .{}; return .{ .gpa = gpa, .master = master, @@ -92,6 +116,7 @@ pub const Harness = struct { .stream = stream, .rows = rows, .cols = cols, + .raw = raw, }; } @@ -99,7 +124,7 @@ pub const Harness = struct { posix.kill(self.pid, posix.SIG.KILL) catch {}; _ = libc.close(self.master); self.stream.deinit(); - self.raw.deinit(self.gpa); + self.gpa.destroy(self.raw); self.term.deinit(self.gpa); self.gpa.destroy(self.term); } @@ -128,7 +153,7 @@ pub const Harness = struct { if ((fds[0].revents & posix.POLL.IN) != 0) { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; - self.raw.appendSlice(self.gpa, buf[0..n]) catch {}; + self.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); } } @@ -147,7 +172,7 @@ pub const Harness = struct { 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.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); return true; } @@ -206,7 +231,7 @@ pub const Harness = struct { if ((fds[0].revents & posix.POLL.IN) != 0) { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; - self.raw.appendSlice(self.gpa, buf[0..n]) catch {}; + self.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); } const text = try self.screenText(); @@ -219,6 +244,11 @@ pub const Harness = struct { if (self.trace) self.traceScreen("waitFor miss"); return false; } + fn recordRaw(self: *Harness, bytes: []const u8) void { + self.raw.appendSlice(bytes) catch |err| switch (err) { + error.CaptureFull => {}, + }; + } /// The visual cursor position (viewport coords): { x, y }. pub fn cursor(self: *Harness) struct { x: u16, y: u16 } { diff --git a/test/hxdiff.zig b/test/hxdiff.zig index f6ab5c6f..599390d6 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -90,7 +90,8 @@ pub fn main(init: std.process.Init) !void { var parity = false; var goldens_path: ?[]const u8 = null; var waivers_path: ?[]const u8 = null; - var case_paths: std.ArrayList([]const u8) = .empty; + const case_paths = arena.alloc([]const u8, args.len - 1) catch fatal("oom", .{}); + var n_case_paths: usize = 0; var want_waivers = false; for (args[1..]) |a| { if (want_waivers) { @@ -100,17 +101,18 @@ pub fn main(init: std.process.Init) !void { parity = true; } else if (std.mem.eql(u8, a, "--waivers")) { want_waivers = true; - } else if (parity or case_paths.items.len == 0) { + } else if (parity or n_case_paths == 0) { // diff mode is positional (cases, goldens, waivers); parity mode // takes any number of case files and nothing else - case_paths.append(arena, a) catch fatal("oom", .{}); + case_paths[n_case_paths] = a; + n_case_paths += 1; } else if (goldens_path == null) { goldens_path = a; } else if (waivers_path == null) { waivers_path = a; } else fatal("usage: pardes-hxdiff [goldens.jsonl [waivers.jsonl]]", .{}); } - if (case_paths.items.len == 0) + if (n_case_paths == 0) fatal("usage: pardes-hxdiff [--parity] ... [goldens.jsonl [waivers.jsonl]]", .{}); // goldens + waivers, keyed by name (diff mode) @@ -144,11 +146,11 @@ pub fn main(init: std.process.Init) !void { } } - var out: std.ArrayList(u8) = .empty; + const stdout = std.Io.File.stdout(); var n_cases: usize = 0; var n_bad: usize = 0; var n_waived: usize = 0; - for (case_paths.items) |path| { + for (case_paths[0..n_case_paths]) |path| { const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); var lines = std.mem.splitScalar(u8, src, '\n'); var lineno: usize = 0; @@ -179,8 +181,8 @@ pub fn main(init: std.process.Init) !void { // null sels/primary are DROPPED, not written: that is what // keeps a one-selection line identical to the old contract const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{}); - out.appendSlice(arena, jl) catch fatal("oom", .{}); - out.append(arena, '\n') catch fatal("oom", .{}); + try stdout.writeStreamingAll(init.io, jl); + try stdout.writeStreamingAll(init.io, "\n"); continue; } const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name}); @@ -195,10 +197,7 @@ pub fn main(init: std.process.Init) !void { std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); } } - if (!parity and goldens_path == null) { - try std.Io.File.stdout().writeStreamingAll(init.io, out.items); - return; - } + if (!parity and goldens_path == null) return; std.debug.print("{s}: {d} cases, {d} mismatches, {d} waived\n", .{ if (parity) "hxparity" else "hxdiff", n_cases, n_bad, n_waived }); if (n_bad > 0) std.process.exit(1); } @@ -235,18 +234,23 @@ const Waiver = struct { /// field-by-field compare; returns a human-readable report ("" = match). /// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty). fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 { - var buf: std.ArrayList(u8) = .empty; + var parts: [5][]const u8 = undefined; + var n: usize = 0; if (!std.mem.eql(u8, want.text, got.text)) { - appendf(arena, &buf, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }); + parts[n] = std.fmt.allocPrint(arena, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch ""; + n += 1; } if (!std.mem.eql(u8, want.mode, got.mode)) { - appendf(arena, &buf, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }); + parts[n] = std.fmt.allocPrint(arena, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch ""; + n += 1; } if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) { - appendf(arena, &buf, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) }); + parts[n] = std.fmt.allocPrint(arena, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) }) catch ""; + n += 1; } if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { - appendf(arena, &buf, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }); + parts[n] = std.fmt.allocPrint(arena, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }) catch ""; + n += 1; } // the whole selection: absent on both sides = one range, and the two // fields above already said everything about it @@ -258,31 +262,58 @@ fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true; }; if (sel_diff) { - appendf(arena, &buf, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }); + parts[n] = std.fmt.allocPrint(arena, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }) catch ""; + n += 1; } - return buf.items; + return concatParts(arena, parts[0..n]); } /// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to /// one pair when they are the same cell), the primary starred fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 { if (sels.len == 0) return ""; - var buf: std.ArrayList(u8) = .empty; - appendf(arena, &buf, "{d} of [", .{sels.len}); + var part_count: usize = 2; for (sels, 0..) |s, i| { - if (i > 0) appendf(arena, &buf, " ", .{}); - if (i == (primary orelse 0)) appendf(arena, &buf, "*", .{}); - appendf(arena, &buf, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }); - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) - appendf(arena, &buf, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }); + part_count += 1; + if (i > 0) part_count += 1; + if (i == (primary orelse 0)) part_count += 1; + if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) part_count += 1; } - appendf(arena, &buf, "]", .{}); - return buf.items; + const parts = arena.alloc([]const u8, part_count) catch return ""; + var n: usize = 0; + parts[n] = std.fmt.allocPrint(arena, "{d} of [", .{sels.len}) catch ""; + n += 1; + for (sels, 0..) |s, i| { + if (i > 0) { + parts[n] = " "; + n += 1; + } + if (i == (primary orelse 0)) { + parts[n] = "*"; + n += 1; + } + parts[n] = std.fmt.allocPrint(arena, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }) catch ""; + n += 1; + if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) { + parts[n] = std.fmt.allocPrint(arena, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }) catch ""; + n += 1; + } + } + parts[n] = "]"; + n += 1; + return concatParts(arena, parts[0..n]); } -fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []const u8, fargs: anytype) void { - const s = std.fmt.allocPrint(arena, fmt, fargs) catch return; - buf.appendSlice(arena, s) catch {}; +fn concatParts(arena: std.mem.Allocator, parts: []const []const u8) []const u8 { + var len: usize = 0; + for (parts) |part| len = std.math.add(usize, len, part.len) catch return ""; + const out = arena.alloc(u8, len) catch return ""; + var offset: usize = 0; + for (parts) |part| { + @memcpy(out[offset..][0..part.len], part); + offset += part.len; + } + return out; } fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result { @@ -297,12 +328,18 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result _ = try core.hxOpenFileContent(case.text); } else if (std.mem.eql(u8, pane_kind, "tty")) { // the harness is the pty here: cook \n to \r\n the way ONLCR would - var cooked: std.ArrayList(u8) = .empty; + const cooked_len = std.math.add(usize, case.text.len, std.mem.count(u8, case.text, "\n")) catch return error.OutOfMemory; + const cooked = try arena.alloc(u8, cooked_len); + var cooked_i: usize = 0; for (case.text) |ch| { - if (ch == '\n') try cooked.append(arena, '\r'); - try cooked.append(arena, ch); + if (ch == '\n') { + cooked[cooked_i] = '\r'; + cooked_i += 1; + } + cooked[cooked_i] = ch; + cooked_i += 1; } - core.update(.{ .output = .{ .pane = 0, .bytes = cooked.items } }); + core.update(.{ .output = .{ .pane = 0, .bytes = cooked } }); pump(core); // leave tty mode the way a live session does: Ctrl-, // then park the cursor at (0,0) — the helix harness's initial @@ -400,12 +437,19 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result // its line list is exactly a file content's — join it and you have the // same bytes, trailing newline included, with nothing to patch up. const pl = try core.paneCursorLines(pane); - var buf: std.ArrayList(u8) = .empty; + var text_len: usize = if (pl.lines.len == 0) 0 else pl.lines.len - 1; + for (pl.lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; + const joined = try arena.alloc(u8, text_len); + var offset: usize = 0; for (pl.lines, 0..) |ln, i| { - if (i > 0) try buf.append(arena, '\n'); - try buf.appendSlice(arena, ln); + if (i > 0) { + joined[offset] = '\n'; + offset += 1; + } + @memcpy(joined[offset..][0..ln.len], ln); + offset += ln.len; } - text = buf.items; + text = joined; } var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col }; if (pane.vsel.active) { @@ -423,7 +467,8 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result var sels: ?[]const Sel = null; var primary: ?usize = null; if (pane.nsel > 0) { - var out: std.ArrayList(Sel) = .empty; + const out = try arena.alloc(Sel, @as(usize, pane.nsel) + 1); + var out_len: usize = 0; const prim: Sel = .{ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor }; const pr_row = @min(prim.cursor.row, prim.anchor.row); const pr_col = if (prim.cursor.row == prim.anchor.row) @min(prim.cursor.col, prim.anchor.col) else if (prim.cursor.row < prim.anchor.row) prim.cursor.col else prim.anchor.col; @@ -431,16 +476,18 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result const s_row = @min(s.row, s.arow); const s_col = if (s.row == s.arow) @min(s.col, s.acol) else if (s.row < s.arow) s.col else s.acol; if (primary == null and (pr_row < s_row or (pr_row == s_row and pr_col <= s_col))) { - primary = out.items.len; - try out.append(arena, prim); + primary = out_len; + out[out_len] = prim; + out_len += 1; } - try out.append(arena, .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } }); + out[out_len] = .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } }; + out_len += 1; } if (primary == null) { - primary = out.items.len; - try out.append(arena, prim); + primary = out_len; + out[out_len] = prim; } - sels = out.items; + sels = out; } return .{ .name = case.name, @@ -464,18 +511,30 @@ fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 { const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); - var buf: std.ArrayList(u8) = .empty; - var chunk: [16384]u8 = undefined; + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed; + const size = std.math.cast(usize, end) orelse return error.FileTooLarge; + const buf = try arena.alloc(u8, size); + var offset: usize = 0; + while (offset < buf.len) { + const n = libc.read(fd, buf[offset..].ptr, buf.len - offset); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) return buf[0..offset]; + offset += @intCast(n); + } + var extra: [1]u8 = undefined; while (true) { - const n = libc.read(fd, &chunk, chunk.len); + const n = libc.read(fd, &extra, extra.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } - if (n == 0) break; - try buf.appendSlice(arena, chunk[0..@intCast(n)]); + if (n > 0) return error.FileChanged; + return buf; } - return buf.items; } fn fatal(comptime fmt: []const u8, args: anytype) noreturn { diff --git a/test/image_harness.zig b/test/image_harness.zig index d4cb8955..601bce49 100644 --- a/test/image_harness.zig +++ b/test/image_harness.zig @@ -73,8 +73,27 @@ fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { _ = libc.rmdir(base); } -fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []const u8, args: anytype) !void { - try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args)); +const pdf_fixture_cap = 4096; + +const BoundedPdf = struct { + bytes: [pdf_fixture_cap]u8 = undefined, + len: usize = 0, + + fn appendSlice(out: *BoundedPdf, bytes: []const u8) !void { + if (bytes.len > out.bytes.len - out.len) return error.FixturePdfTooLarge; + @memcpy(out.bytes[out.len..][0..bytes.len], bytes); + out.len += bytes.len; + } + + fn written(out: *const BoundedPdf) []const u8 { + return out.bytes[0..out.len]; + } +}; + +fn appendFmt(out: *BoundedPdf, comptime fmt: []const u8, args: anytype) !void { + const bytes = std.fmt.bufPrint(out.bytes[out.len..], fmt, args) catch + return error.FixturePdfTooLarge; + out.len += bytes.len; } /// A self-contained, valid PDF fixture. The first two pages retain the red / @@ -83,6 +102,7 @@ fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: [] /// headers. Building it here keeps the backend test independent of mutool, /// fonts, and checked-in binary fixtures while the xref offsets remain exact. fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void { + _ = arena; const first_stream = \\1 0 0 rg \\0 0 72 48 re @@ -128,54 +148,63 @@ fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void { \\ET ; - var out: std.ArrayList(u8) = .empty; + var out: BoundedPdf = .{}; var offsets: [16]usize = @splat(0); - try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); - - offsets[1] = out.items.len; - try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 12 0 R /PageMode /UseOutlines >>\nendobj\n"); - offsets[2] = out.items.len; - try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n"); - offsets[3] = out.items.len; - try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n"); - offsets[4] = out.items.len; - try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream }); - offsets[5] = out.items.len; - try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n"); - offsets[6] = out.items.len; - try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream }); - offsets[7] = out.items.len; - try out.appendSlice(arena, "7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n"); - offsets[8] = out.items.len; - try appendFmt(arena, &out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream }); - offsets[9] = out.items.len; - try out.appendSlice(arena, "9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n"); - offsets[10] = out.items.len; - try appendFmt(arena, &out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream }); - offsets[11] = out.items.len; - try out.appendSlice(arena, "11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); - offsets[12] = out.items.len; - try out.appendSlice(arena, "12 0 obj\n<< /Type /Outlines /First 13 0 R /Last 15 0 R /Count 3 >>\nendobj\n"); - offsets[13] = out.items.len; - try out.appendSlice(arena, "13 0 obj\n<< /Title (Upper) /Parent 12 0 R /First 14 0 R /Last 14 0 R /Next 15 0 R /Count 1 /Dest [3 0 R /XYZ 4 8 null] >>\nendobj\n"); - offsets[14] = out.items.len; - try out.appendSlice(arena, "14 0 obj\n<< /Title (Lower) /Parent 13 0 R /Dest [3 0 R /XYZ 4 28 null] >>\nendobj\n"); - offsets[15] = out.items.len; - try out.appendSlice(arena, "15 0 obj\n<< /Title (Page two) /Parent 12 0 R /Prev 13 0 R /Dest [5 0 R /XYZ 4 38 null] >>\nendobj\n"); - - const xref = out.items.len; - try out.appendSlice(arena, "xref\n0 16\n0000000000 65535 f \n"); + try out.appendSlice("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); + + offsets[1] = out.len; + try out.appendSlice("1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 12 0 R /PageMode /UseOutlines >>\nendobj\n"); + offsets[2] = out.len; + try out.appendSlice("2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n"); + offsets[3] = out.len; + try out.appendSlice("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n"); + offsets[4] = out.len; + try appendFmt(&out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream }); + offsets[5] = out.len; + try out.appendSlice("5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n"); + offsets[6] = out.len; + try appendFmt(&out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream }); + offsets[7] = out.len; + try out.appendSlice("7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n"); + offsets[8] = out.len; + try appendFmt(&out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream }); + offsets[9] = out.len; + try out.appendSlice("9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n"); + offsets[10] = out.len; + try appendFmt(&out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream }); + offsets[11] = out.len; + try out.appendSlice("11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + offsets[12] = out.len; + try out.appendSlice("12 0 obj\n<< /Type /Outlines /First 13 0 R /Last 15 0 R /Count 3 >>\nendobj\n"); + offsets[13] = out.len; + try out.appendSlice("13 0 obj\n<< /Title (Upper) /Parent 12 0 R /First 14 0 R /Last 14 0 R /Next 15 0 R /Count 1 /Dest [3 0 R /XYZ 4 8 null] >>\nendobj\n"); + offsets[14] = out.len; + try out.appendSlice("14 0 obj\n<< /Title (Lower) /Parent 13 0 R /Dest [3 0 R /XYZ 4 28 null] >>\nendobj\n"); + offsets[15] = out.len; + try out.appendSlice("15 0 obj\n<< /Title (Page two) /Parent 12 0 R /Prev 13 0 R /Dest [5 0 R /XYZ 4 38 null] >>\nendobj\n"); + + const xref = out.len; + try out.appendSlice("xref\n0 16\n0000000000 65535 f \n"); for (offsets[1..]) |offset| - try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset}); - try appendFmt(arena, &out, "trailer\n<< /Size 16 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); - try eh.writeFile(path, out.items); + try appendFmt(&out, "{d:0>10} 00000 n \n", .{offset}); + try appendFmt(&out, "trailer\n<< /Size 16 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); + try eh.writeFile(path, out.written()); } fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void { - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height })); - for (0..width * height) |_| try out.appendSlice(arena, &fixture_rgb); - try eh.writeFile(path, out.items); + var header_buf: [64]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "P6\n{d} {d}\n255\n", .{ width, height }) catch + return error.FixturePpmHeaderTooLarge; + const pixel_count = try std.math.mul(usize, width, height); + const pixel_bytes = try std.math.mul(usize, pixel_count, fixture_rgb.len); + const out = try arena.alloc(u8, try std.math.add(usize, header.len, pixel_bytes)); + @memcpy(out[0..header.len], header); + var offset = header.len; + for (0..pixel_count) |_| { + @memcpy(out[offset..][0..fixture_rgb.len], &fixture_rgb); + offset += fixture_rgb.len; + } + try eh.writeFile(path, out); } fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { @@ -656,16 +685,18 @@ fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(allocator); - var buf: [8192]u8 = undefined; - while (true) { - const n = libc.read(fd, &buf, buf.len); + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; + const out = try allocator.alloc(u8, @intCast(end)); + errdefer allocator.free(out); + var filled: usize = 0; + while (filled < out.len) { + const n = libc.read(fd, out[filled..].ptr, out.len - filled); if (n < 0) return error.ReadFailed; - if (n == 0) break; - try out.appendSlice(allocator, buf[0..@intCast(n)]); + if (n == 0) return error.UnexpectedEof; + filled += @intCast(n); } - return out.toOwnedSlice(allocator); + return out; } fn ppmPixels(bytes: []const u8) ?[]const u8 { diff --git a/test/lspbench.zig b/test/lspbench.zig index 329e5743..964d3c8c 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -147,8 +147,8 @@ pub fn main(init: std.process.Init) !void { } const rss0 = rssKib(); - var results: std.ArrayList(Result) = .empty; - defer results.deinit(gpa); + var results: [anchors.len]Result = undefined; + var result_count: usize = 0; for (anchors) |an| { const path = try std.fs.path.join(gpa, &.{ root, an.file }); @@ -193,11 +193,12 @@ pub fn main(init: std.process.Init) !void { lsp.query(gpa, arena.allocator(), req, &out.writer); break :blk std.mem.indexOf(u8, out.written(), an.expect) != null; }); - try results.append(gpa, r); + results[result_count] = r; + result_count += 1; } const rss = rssKib() -| rss0; - if (json) reportJson(results.items, rss) else reportText(results.items, rss); + if (json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); } fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, r: *Result) u64 { @@ -264,19 +265,23 @@ fn readZ(gpa: std.mem.Allocator, path: []const u8) ![:0]u8 { const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(gpa); - var chunk: [16384]u8 = undefined; - while (true) { - const n = libc.read(fd, &chunk, chunk.len); + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; + const len: usize = @intCast(end); + const buf = try gpa.alloc(u8, len + 1); + errdefer gpa.free(buf); + var filled: usize = 0; + while (filled < len) { + const n = libc.read(fd, buf[filled..].ptr, len - filled); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } - if (n == 0) break; - try buf.appendSlice(gpa, chunk[0..@intCast(n)]); + if (n == 0) return error.UnexpectedEof; + filled += @intCast(n); } - return buf.toOwnedSliceSentinel(gpa, 0); + buf[len] = 0; + return buf[0..len :0]; } /// VmHWM from /proc/self/status — the peak, not the current, so a backend that diff --git a/test/pdf_bench.zig b/test/pdf_bench.zig index fb911bee..0f09f72a 100644 --- a/test/pdf_bench.zig +++ b/test/pdf_bench.zig @@ -470,6 +470,8 @@ fn mib(bytes: u64) f64 { return @as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0); } +const report_max_bytes = 64 * 1024; + fn reportText( io: std.Io, config: Config, @@ -477,10 +479,9 @@ fn reportText( multi_pages: usize, results: []const Result, ) void { - var out: std.ArrayList(u8) = .empty; - defer out.deinit(gpa); + var storage: [report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); out.print( - gpa, "pardes PDF render benchmark (ReleaseFast)\n" ++ "path: {s} ({d} pages), selected page: {d}, multi-page pass: {d} pages\n" ++ "warmup: {d}, sampled reps: {d}; cold = fresh Document (OS cache uncontrolled)\n" ++ @@ -500,8 +501,7 @@ fn reportText( tint_colors.foreground[2], }, ) catch return; - out.appendSlice( - gpa, + out.writeAll( "profile scenario min ms median p90 max ms MPix/s GiB/s pixels/op RGBA MiB checksum\n" ++ "-----------------------------------------------------------------------------------------------------------------------------------\n", ) catch return; @@ -509,7 +509,6 @@ fn reportText( const profile = profiles[result.profile]; const scenario = scenarios[result.scenario]; out.print( - gpa, "{s:<20} {s:<26} {d:>8.2} {d:>8.2} {d:>8.2} {d:>8.2} {d:>7.1} {d:>6.2} {d:>10} {d:>9.2} {x:0>16}\n", .{ profile.id, @@ -526,10 +525,9 @@ fn reportText( }, ) catch return; } - out.appendSlice(gpa, "\nrequests:\n") catch return; + out.writeAll("\nrequests:\n") catch return; for (profiles) |profile| { out.print( - gpa, " {s}: dpi={d}, viewport-min={d}x{d}, max-dimension={d} — {s}\n", .{ profile.id, @@ -541,7 +539,7 @@ fn reportText( }, ) catch return; } - std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; + std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } fn reportJson( @@ -551,12 +549,11 @@ fn reportJson( multi_pages: usize, results: []const Result, ) void { - var out: std.ArrayList(u8) = .empty; - defer out.deinit(gpa); + var storage: [report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); const path_json = std.json.Stringify.valueAlloc(gpa, config.path, .{}) catch return; defer gpa.free(path_json); out.print( - gpa, "{{\"benchmark\":\"pardes-pdf-render\",\"build\":\"ReleaseFast\"," ++ "\"path\":{s},\"document_pages\":{d},\"selected_page\":{d}," ++ "\"multi_pages\":{d},\"warmup\":{d},\"reps\":{d}," ++ @@ -579,7 +576,6 @@ fn reportJson( ) catch return; for (profiles, 0..) |profile, i| { out.print( - gpa, "{s}{{\"id\":\"{s}\",\"dpi\":{d},\"minimum_width\":{d}," ++ "\"minimum_height\":{d},\"max_dimension\":{d}}}", .{ @@ -592,10 +588,9 @@ fn reportJson( }, ) catch return; } - out.appendSlice(gpa, "],\"results\":[") catch return; + out.writeAll("],\"results\":[") catch return; for (results, 0..) |result, i| { out.print( - gpa, "{s}{{\"profile\":\"{s}\",\"scenario\":\"{s}\"," ++ "\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d}," ++ "\"max_ns\":{d},\"sampled_ns\":{d},\"pixels\":{d}," ++ @@ -618,8 +613,8 @@ fn reportJson( }, ) catch return; } - out.appendSlice(gpa, "]}\n") catch return; - std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; + out.writeAll("]}\n") catch return; + std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } fn fatal(comptime format: []const u8, args: anytype) noreturn { diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig index 21327c9f..9d44a3dd 100644 --- a/test/pdf_sections_bench.zig +++ b/test/pdf_sections_bench.zig @@ -116,8 +116,8 @@ fn parseCount(flag: []const u8, text: []const u8) usize { } fn measureOutline(path: []const u8, config: Config) !Result { - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, config.reps); + defer gpa.free(samples); var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { // Document open/close is setup: this row isolates MuPDF outline load, @@ -130,9 +130,9 @@ fn measureOutline(path: []const u8, config: Config) !Result { const identity = outlineIdentity(outline.entries); outline.deinit(gpa); try verifyIdentity("outline-load-flatten-real", &expected, identity, round); - if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed)); + if (round >= config.warmup) samples[round - config.warmup] = @max(1, elapsed); } - return summarize("outline-load-flatten-real", 1, samples.items, expected.?); + return summarize("outline-load-flatten-real", 1, samples, expected.?); } fn measureMaterialize( @@ -140,8 +140,8 @@ fn measureMaterialize( entries: []const pdf.OutlineEntry, config: Config, ) !Result { - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, config.reps); + defer gpa.free(samples); var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { const started = nowNs(); @@ -150,17 +150,17 @@ fn measureMaterialize( const identity = contentIdentity(content); gpa.free(content); try verifyIdentity("sections-first-materialize", &expected, identity, round); - if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed)); + if (round >= config.warmup) samples[round - config.warmup] = @max(1, elapsed); } - return summarize("sections-first-materialize", 1, samples.items, expected.?); + return summarize("sections-first-materialize", 1, samples, expected.?); } fn measureCachedReopen(path: []const u8, config: Config) !Result { const core = try preparedCore(path); defer core.deinit(); const prepared = preparedReopen(core); - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, config.reps); + defer gpa.free(samples); var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { var observed: u64 = 0; @@ -182,9 +182,9 @@ fn measureCachedReopen(path: []const u8, config: Config) !Result { identity.checksum = mix(identity.checksum, observed); try verifyIdentity("sections-clean-cached-reopen", &expected, identity, round); if (round >= config.warmup) - try samples.append(gpa, @max(1, elapsed / cached_batch)); + samples[round - config.warmup] = @max(1, elapsed / cached_batch); } - return summarize("sections-clean-cached-reopen", cached_batch, samples.items, expected.?); + return summarize("sections-clean-cached-reopen", cached_batch, samples, expected.?); } /// Cost floor for the anti-elision workload above. This performs the same @@ -194,8 +194,8 @@ fn measureCachedControl(path: []const u8, config: Config) !Result { const core = try preparedCore(path); defer core.deinit(); const prepared = preparedReopen(core); - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, config.reps); + defer gpa.free(samples); var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { var observed: u64 = 0; @@ -212,17 +212,17 @@ fn measureCachedControl(path: []const u8, config: Config) !Result { identity.checksum = mix(identity.checksum, observed); try verifyIdentity("sections-perturb-observe-control", &expected, identity, round); if (round >= config.warmup) - try samples.append(gpa, @max(1, elapsed / cached_batch)); + samples[round - config.warmup] = @max(1, elapsed / cached_batch); } - return summarize("sections-perturb-observe-control", cached_batch, samples.items, expected.?); + return summarize("sections-perturb-observe-control", cached_batch, samples, expected.?); } fn measureOrdinalStep(path: []const u8, config: Config) !Result { const core = try preparedCore(path); defer core.deinit(); const owner = core.panes[0].?; - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, config.reps); + defer gpa.free(samples); var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { owner.search_row = null; @@ -244,14 +244,14 @@ fn measureOrdinalStep(path: []const u8, config: Config) !Result { const identity = stepIdentity(core); try verifyIdentity("sections-ordinal-destination-step", &expected, identity, round); if (round >= config.warmup) - try samples.append(gpa, @max(1, elapsed / step_batch)); + samples[round - config.warmup] = @max(1, elapsed / step_batch); } - return summarize("sections-ordinal-destination-step", step_batch, samples.items, expected.?); + return summarize("sections-ordinal-destination-step", step_batch, samples, expected.?); } fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result { - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, config.reps); + defer gpa.free(samples); var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { const started = nowNs(); @@ -260,9 +260,9 @@ fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result const identity = contentIdentity(content); gpa.free(content); try verifyIdentity("rows-near-limit-worst-shape", &expected, identity, round); - if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed)); + if (round >= config.warmup) samples[round - config.warmup] = @max(1, elapsed); } - return summarize("rows-near-limit-worst-shape", 1, samples.items, expected.?); + return summarize("rows-near-limit-worst-shape", 1, samples, expected.?); } fn preparedCore(path: []const u8) !*pardes.Pardes { @@ -495,14 +495,15 @@ fn reportText(config: Config, results: []const Result) void { std.debug.print("\nBatched rows divide elapsed time by operations/sample: cached={d}, step={d}.\n", .{ cached_batch, step_batch }); } +const json_report_max_bytes = 16 * 1024; + fn reportJson(io: std.Io, config: Config, results: []const Result) void { - var out: std.ArrayList(u8) = .empty; - defer out.deinit(gpa); - out.print(gpa, "{{\"benchmark\":\"pardes-pdf-sections\",\"build\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[", .{ + var storage: [json_report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); + out.print("{{\"benchmark\":\"pardes-pdf-sections\",\"build\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[", .{ config.warmup, config.reps, }) catch return; for (results, 0..) |result, i| out.print( - gpa, "{s}{{\"scenario\":\"{s}\",\"operations_per_sample\":{d},\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"max_ns\":{d},\"items\":{d},\"bytes\":{d},\"checksum\":\"{x:0>16}\"}}", .{ if (i == 0) "" else ",", @@ -517,8 +518,8 @@ fn reportJson(io: std.Io, config: Config, results: []const Result) void { result.identity.checksum, }, ) catch return; - out.appendSlice(gpa, "]}\n") catch return; - std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; + out.writeAll("]}\n") catch return; + std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } fn writeFile(path: []const u8, bytes: []const u8) !void { diff --git a/test/perf.zig b/test/perf.zig index 368e6ce1..bf5f0200 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -166,8 +166,8 @@ pub fn main(init: std.process.Init) !void { /// big a difference has to be before it is real. fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { const warmup = 3; - var samples: std.ArrayList(u64) = .empty; - defer samples.deinit(gpa); + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); if (op == .open) { // fresh core per sample: opening is a one-shot, and the second Look at @@ -180,9 +180,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; - if (n >= warmup) try samples.append(gpa, dt / 1000); + if (n >= warmup) samples[n - warmup] = dt / 1000; } - return summarize(samples.items); + return summarize(samples); } const core = try boot(); @@ -217,9 +217,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; - if (n >= warmup) try samples.append(gpa, dt / 1000); + if (n >= warmup) samples[n - warmup] = dt / 1000; } - return summarize(samples.items); + return summarize(samples); } /// Park the cursor and the view at sample `n`'s position, walked across the @@ -291,22 +291,39 @@ fn summarize(samples: []u64) Cell { /// lines get their width from a comment tail — the alternative (an enormous /// string literal) makes the whole file one token and flatters every scan that /// looks for a newline. +fn generatedPrefixLen(n: usize) usize { + return switch (n % 4) { + 0 => std.fmt.count("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), + 1 => std.fmt.count("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }), + 2 => std.fmt.count(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }), + else => std.fmt.count("// comment line {d} — ", .{n}), + }; +} + +fn writeGeneratedPrefix(out: *std.Io.Writer, n: usize) !void { + switch (n % 4) { + 0 => try out.print("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), + 1 => try out.print("pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }), + 2 => try out.print(" const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }), + else => try out.print("// comment line {d} — ", .{n}), + } +} + fn generate(fx: Fixture) ![]u8 { - var out: std.ArrayList(u8) = .empty; - try out.ensureTotalCapacity(gpa, fx.lines * (fx.cols + 1) + 64); - var n: usize = 0; - while (n < fx.lines) : (n += 1) { - const start = out.items.len; - switch (n % 4) { - 0 => try out.print(gpa, "const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), - 1 => try out.print(gpa, "pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }), - 2 => try out.print(gpa, " const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }), - else => try out.print(gpa, "// comment line {d} — ", .{n}), - } - while (out.items.len - start < fx.cols) try out.append(gpa, 'x'); - try out.append(gpa, '\n'); + var byte_len: usize = 0; + for (0..fx.lines) |n| byte_len += @max(generatedPrefixLen(n), fx.cols) + 1; + const text = try gpa.alloc(u8, byte_len); + errdefer gpa.free(text); + var out: std.Io.Writer = .fixed(text); + for (0..fx.lines) |n| { + const start = out.end; + try writeGeneratedPrefix(&out, n); + const prefix_len = out.end - start; + if (prefix_len < fx.cols) try out.splatByteAll('x', fx.cols - prefix_len); + try out.writeByte('\n'); } - return out.toOwnedSlice(gpa); + std.debug.assert(out.end == text.len); + return text; } fn writeFile(path: []const u8, text: []const u8) !void { @@ -387,28 +404,30 @@ fn reportText(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: /// Real stdout, not std.debug.print's stderr: this is the form `--base` reads /// back, and `zig build perf -- --json > runs/old.json` writing an empty file /// would make the next comparison silently print no ratios at all. +const json_report_max_bytes = 32 * 1024; + fn reportJson(io: std.Io, cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize) void { - var out: std.ArrayList(u8) = .empty; - defer out.deinit(gpa); - out.print(gpa, "{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return; + var storage: [json_report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); + out.print("{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return; for (fixtures, 0..) |fx, fi| { - out.print(gpa, "{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ + out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi], }) catch return; } - out.appendSlice(gpa, "],\"cells\":[") catch return; + out.writeAll("],\"cells\":[") catch return; var first = true; for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { const c = cells[oi][fi]; - out.print(gpa, "{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us, }) catch return; first = false; } } - out.appendSlice(gpa, "]}\n") catch return; - std.Io.File.stdout().writeStreamingAll(io, out.items) catch {}; + out.writeAll("]}\n") catch return; + std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } const Base = struct { med: [std.enums.values(Op).len][fixtures.len]u64 }; @@ -420,6 +439,7 @@ const Base = struct { med: [std.enums.values(Op).len][fixtures.len]u64 }; /// and did not get is worse than no comparison. fn readBase(path: []const u8) ?Base { const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path}); + defer gpa.free(src); var b: Base = .{ .med = @splat(@splat(0)) }; const parsed = std.json.parseFromSlice(struct { cells: []const struct { @@ -446,18 +466,22 @@ fn readFileAlloc(path: []const u8) ![]u8 { const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); - var buf: std.ArrayList(u8) = .empty; - var chunk: [16384]u8 = undefined; - while (true) { - const n = libc.read(fd, &chunk, chunk.len); + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed; + const size = std.math.cast(usize, end) orelse return error.FileTooLarge; + const buf = try gpa.alloc(u8, size); + errdefer gpa.free(buf); + var offset: usize = 0; + while (offset < buf.len) { + const n = libc.read(fd, buf.ptr + offset, buf.len - offset); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } - if (n == 0) break; - try buf.appendSlice(gpa, chunk[0..@intCast(n)]); + if (n == 0) return error.UnexpectedEndOfFile; + offset += @intCast(n); } - return buf.items; + return buf; } fn fatal(comptime fmt: []const u8, args: anytype) noreturn { diff --git a/test/snapshot.zig b/test/snapshot.zig index 36a5b20b..c3611291 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -91,7 +91,7 @@ pub fn main(init: std.process.Init) !void { // 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; + var script_count: usize = 0; for (args[1..]) |a| { if (std.mem.eql(u8, a, "--update")) { update = true; @@ -102,28 +102,42 @@ pub fn main(init: std.process.Init) !void { } else if (exe == null) { exe = try absPath(arena, orig_cwd, a); } else { - try scripts.append(arena, try absPath(arena, orig_cwd, a)); + script_count += 1; } } + var scripts = try arena.alloc([]const u8, script_count); + var script_i: usize = 0; + var saw_exe = false; + for (args[1..]) |a| { + if (std.mem.eql(u8, a, "--update") or + std.mem.eql(u8, a, "--trace-stable") or + std.mem.startsWith(u8, a, "--jobs=")) continue; + if (!saw_exe) { + saw_exe = true; + continue; + } + scripts[script_i] = try absPath(arena, orig_cwd, a); + script_i += 1; + } if (exe == null) fatal("usage: pardes-snap [scripts...] [--update] [--jobs=N]", .{}); const exe_z = try arena.dupeZ(u8, exe.?); - if (scripts.items.len == 0) { - try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"), &scripts); - std.mem.sort([]const u8, scripts.items, {}, struct { + if (scripts.len == 0) { + scripts = try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots")); + std.mem.sort([]const u8, scripts, {}, struct { fn lt(_: void, x: []const u8, y: []const u8) bool { return std.mem.lessThan(u8, x, y); } }.lt); } - if (scripts.items.len == 0) fatal("no .snap scripts found", .{}); + if (scripts.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); + const codes = try arena.alloc(u8, scripts.len); + try runBatch(arena, exe_z, toBatch(arena, scripts), 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 @@ -135,11 +149,21 @@ pub fn main(init: std.process.Init) !void { 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 }; + var retry_count: usize = 0; + for (codes) |c| { + if (c != 0) retry_count += 1; + } + if (retry_count == 0 or retry_count > 10) break; + const retry = try arena.alloc(usize, retry_count); + var retry_i: usize = 0; + for (codes, 0..) |c, i| { + if (c != 0) { + retry[retry_i] = i; + retry_i += 1; + } + } + const paths = try arena.alloc(Idx, retry.len); + for (retry, paths, 0..) |i, *p, n| p.* = .{ .path = scripts[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", @@ -149,12 +173,12 @@ pub fn main(init: std.process.Init) !void { 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; + for (retry, recodes) |i, c| codes[i] = c; } } var failed: usize = 0; - for (scripts.items, codes) |script_path, code| { + for (scripts, codes) |script_path, code| { const report = readFileAlloc(arena, reportPath(arena, script_path) catch "") catch ""; std.debug.print("{s}", .{report}); if (code != 0) { @@ -164,10 +188,10 @@ pub fn main(init: std.process.Init) !void { } } if (failed > 0) { - std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.items.len }); + std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.len }); std.process.exit(1); } - std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len}); + std.debug.print("all {d} snapshot scripts ok\n", .{scripts.len}); } /// Run `scripts` `jobs` at a time, one forked child each, filling `codes` with @@ -198,24 +222,26 @@ fn runBatch( codes: []u8, ) !void { const Job = struct { pid: libc.pid_t, idx: usize }; - var running: std.ArrayList(Job) = .empty; - defer running.deinit(arena); + const running = try arena.alloc(Job, @min(jobs, scripts.len)); + var running_len: usize = 0; var next: usize = 0; - while (next < scripts.len or running.items.len > 0) { - while (next < scripts.len and running.items.len < jobs) : (next += 1) { + while (next < scripts.len or running_len > 0) { + while (next < scripts.len and running_len < running.len) : (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 }); + running[running_len] = .{ .pid = pid, .idx = scripts[idx].code_idx }; + running_len += 1; } var status: c_int = 0; const pid = libc.waitpid(-1, &status, 0); - for (running.items, 0..) |j, i| { + for (running[0..running_len], 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); + running_len -= 1; + running[i] = running[running_len]; break; } } @@ -337,7 +363,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const _ = 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; + const chunks = try arena.alloc([]const u8, countCaptures(src)); + var chunk_len: usize = 0; var h: ?eh.Harness = null; defer if (h) |*hp| hp.deinit(); @@ -368,9 +395,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const // one) — a file that is long AND wide, which is what a horizontal // scroll test needs and `file` can only give as one huge script line const tail = tok.rest(); - var buf: std.ArrayList(u8) = .empty; - for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}{s}\n", .{ i, tail })); - try eh.writeFile(try arena.dupeZ(u8, name), buf.items); + const buf = try numberedLines(arena, n, tail); + try eh.writeFile(try arena.dupeZ(u8, name), buf); } else if (std.mem.eql(u8, cmd, "ppmdiag")) { // The diagonal-split test PPM from the old e2e (white over blue), // grown to 512x512 with the white half filled by a fixed LCG @@ -381,19 +407,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const // the art, which a 64x64 image cannot show (its cells average // down to flat color and a block always wins). const name = tok.next() orelse return error.BadScript; - var ppm: std.ArrayList(u8) = .empty; - try ppm.appendSlice(arena, "P6\n512 512\n255\n"); - var seed: u64 = 12345; - var py: usize = 0; - while (py < 512) : (py += 1) { - var px: usize = 0; - while (px < 512) : (px += 1) { - seed = seed *% 1103515245 +% 12345; - const v: u8 = @truncate(seed >> 16); - try ppm.appendSlice(arena, if (px + py < 512) &[_]u8{ v, v, v } else &[_]u8{ 0, 0, 255 }); - } - } - try eh.writeFile(try arena.dupeZ(u8, name), ppm.items); + const ppm = try ppmDiag(arena); + try eh.writeFile(try arena.dupeZ(u8, name), ppm); } else if (std.mem.eql(u8, cmd, "dirmk")) { try mkdir(arena, tok.next() orelse return error.BadScript, false); } else if (std.mem.eql(u8, cmd, "run")) { @@ -418,22 +433,10 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const } else if (std.mem.eql(u8, cmd, "snapfile")) { const label = tok.next() orelse return error.BadScript; const contents = try readFileAlloc(arena, tok.rest()); - try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== file {s}\n", .{label})); - // elide base64 payloads: they encode raw pty byte HISTORY, whose + // Elide base64 payloads: they encode raw pty byte HISTORY, whose // micro-timing (prompt redraw cycles) legitimately differs between // implementations; the cleaned text fields are the contract. - var flines = std.mem.splitScalar(u8, contents, '\n'); - while (flines.next()) |fl| { - if (std.mem.indexOf(u8, fl, "_b64 = \"") != null) { - const eq = std.mem.indexOf(u8, fl, "=").?; - try out.appendSlice(arena, fl[0 .. eq + 1]); - try out.appendSlice(arena, " \n"); - } else { - try out.appendSlice(arena, fl); - try out.append(arena, '\n'); - } - } - try out.appendSlice(arena, "== end file\n"); + try appendChunk(chunks, &chunk_len, try snapFile(arena, label, contents)); } else if (std.mem.eql(u8, cmd, "wait")) { const ms = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10); try live(&h).expectWaitFor(tok.rest(), ms, "snapshot wait"); @@ -471,15 +474,119 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10); try live(&h).resize(rows, cols); } else if (std.mem.eql(u8, cmd, "snap")) { - try snapText(arena, &out, live(&h), tok.rest()); + try appendChunk(chunks, &chunk_len, try snapText(arena, live(&h), tok.rest())); } else if (std.mem.eql(u8, cmd, "snapstyle")) { - try snapStyle(arena, &out, live(&h), tok.rest()); + try appendChunk(chunks, &chunk_len, try snapStyle(arena, live(&h), tok.rest())); } else { std.debug.print("unknown command {s}\n", .{cmd}); return error.BadScript; } } - return out.items; + return concatChunks(arena, chunks[0..chunk_len]); +} + +fn countCaptures(src: []const u8) usize { + var count: usize = 0; + var lines = std.mem.splitScalar(u8, src, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0 or line[0] == '#') continue; + var tok = std.mem.tokenizeScalar(u8, line, ' '); + const cmd = tok.next().?; + if (std.mem.eql(u8, cmd, "snap") or + std.mem.eql(u8, cmd, "snapstyle") or + std.mem.eql(u8, cmd, "snapfile")) count += 1; + } + return count; +} + +fn appendChunk(chunks: [][]const u8, len: *usize, chunk: []const u8) !void { + if (len.* == chunks.len) return error.BadScript; + chunks[len.*] = chunk; + len.* += 1; +} + +fn concatChunks(arena: std.mem.Allocator, chunks: []const []const u8) ![]u8 { + var total: usize = 0; + for (chunks) |chunk| total += chunk.len; + const out = try arena.alloc(u8, total); + var pos: usize = 0; + for (chunks) |chunk| copyBytes(out, &pos, chunk); + return out; +} + +fn copyBytes(out: []u8, pos: *usize, bytes: []const u8) void { + @memcpy(out[pos.*..][0..bytes.len], bytes); + pos.* += bytes.len; +} + +fn decimalDigits(value: usize) usize { + var v = value; + var digits: usize = 1; + while (v >= 10) : (digits += 1) v /= 10; + return digits; +} + +fn numberedLines(arena: std.mem.Allocator, n: usize, tail: []const u8) ![]u8 { + var total: usize = 0; + for (1..n + 1) |i| total += "line ".len + decimalDigits(i) + tail.len + 1; + const out = try arena.alloc(u8, total); + var pos: usize = 0; + for (1..n + 1) |i| { + const line = try std.fmt.bufPrint(out[pos..], "line {d}{s}\n", .{ i, tail }); + pos += line.len; + } + return out; +} + +fn ppmDiag(arena: std.mem.Allocator) ![]u8 { + const header = "P6\n512 512\n255\n"; + const out = try arena.alloc(u8, header.len + 512 * 512 * 3); + @memcpy(out[0..header.len], header); + var pos = header.len; + var seed: u64 = 12345; + var py: usize = 0; + while (py < 512) : (py += 1) { + var px: usize = 0; + while (px < 512) : (px += 1) { + seed = seed *% 1103515245 +% 12345; + const v: u8 = @truncate(seed >> 16); + const pixel: [3]u8 = if (px + py < 512) .{ v, v, v } else .{ 0, 0, 255 }; + @memcpy(out[pos..][0..pixel.len], &pixel); + pos += pixel.len; + } + } + return out; +} + +fn snapFile(arena: std.mem.Allocator, label: []const u8, contents: []const u8) ![]u8 { + const header = try std.fmt.allocPrint(arena, "== file {s}\n", .{label}); + var total = header.len + "== end file\n".len; + var lines = std.mem.splitScalar(u8, contents, '\n'); + while (lines.next()) |line| { + if (std.mem.indexOf(u8, line, "_b64 = \"") != null) { + const eq = std.mem.indexOfScalar(u8, line, '=').?; + total += eq + 1 + " \n".len; + } else { + total += line.len + 1; + } + } + const out = try arena.alloc(u8, total); + var pos: usize = 0; + copyBytes(out, &pos, header); + lines = std.mem.splitScalar(u8, contents, '\n'); + while (lines.next()) |line| { + if (std.mem.indexOf(u8, line, "_b64 = \"") != null) { + const eq = std.mem.indexOfScalar(u8, line, '=').?; + copyBytes(out, &pos, line[0 .. eq + 1]); + copyBytes(out, &pos, " \n"); + } else { + copyBytes(out, &pos, line); + copyBytes(out, &pos, "\n"); + } + } + copyBytes(out, &pos, "== end file\n"); + return out; } fn live(h: *?eh.Harness) *eh.Harness { @@ -649,48 +756,78 @@ fn stateKey(h: *eh.Harness) ![]u8 { return std.fmt.allocPrint(h.gpa, "{d},{d},{x}\n{s}", .{ c.x, c.y, styles.final(), text }); } -fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void { +fn snapText(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8) ![]u8 { const text = try h.screenText(); defer h.gpa.free(text); const c = h.cursor(); - try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y })); + const header = try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y }); + var total = header.len; var it = std.mem.splitScalar(u8, text, '\n'); var row: usize = 0; while (row < h.rows) : (row += 1) { const line = it.next() orelse ""; - try out.append(arena, '|'); - try out.appendSlice(arena, std.mem.trimEnd(u8, line, " ")); - try out.append(arena, '\n'); + total += 1 + std.mem.trimEnd(u8, line, " ").len + 1; } + const out = try arena.alloc(u8, total); + var pos: usize = 0; + copyBytes(out, &pos, header); + it = std.mem.splitScalar(u8, text, '\n'); + row = 0; + while (row < h.rows) : (row += 1) { + const line = it.next() orelse ""; + copyBytes(out, &pos, "|"); + copyBytes(out, &pos, std.mem.trimEnd(u8, line, " ")); + copyBytes(out, &pos, "\n"); + } + return out; } /// Per-row style runs from the harness emulator: `|y: x0-x1 fg,bg,attrs ...`. /// fg/bg: d (default) | pN (palette) | #rrggbb. attrs: b f i k r v x + u U ~ . - /// for underline single/double/curly/dotted/dashed. -fn snapStyle(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void { - try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== style {s}\n", .{label})); +fn snapStyle(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8) ![]u8 { + const header = try std.fmt.allocPrint(arena, "== style {s}\n", .{label}); + const rows_len = styleRows(h, null); + const out = try arena.alloc(u8, header.len + rows_len); + var pos: usize = 0; + copyBytes(out, &pos, header); + _ = styleRows(h, out[pos..]); + return out; +} + +fn styleRows(h: *eh.Harness, dst: ?[]u8) usize { + var pos: usize = 0; var y: u16 = 0; while (y < h.rows) : (y += 1) { - try out.appendSlice(arena, try std.fmt.allocPrint(arena, "|{d}:", .{y})); - var run_buf: [64]u8 = undefined; + var fmt_buf: [64]u8 = undefined; + emitBytes(dst, &pos, std.fmt.bufPrint(&fmt_buf, "|{d}:", .{y}) catch unreachable); var key_buf: [64]u8 = undefined; - var run_key: []const u8 = ""; + var run_key: [64]u8 = undefined; + var run_len: usize = 0; var run_start: u16 = 0; var x: u16 = 0; while (x <= h.cols) : (x += 1) { const key: []const u8 = if (x == h.cols) "\x00end" else cellKey(h, x, y, &key_buf); if (x == 0) { - run_key = try arena.dupe(u8, key); + @memcpy(run_key[0..key.len], key); + run_len = key.len; continue; } - if (std.mem.eql(u8, key, run_key)) continue; - try out.appendSlice(arena, try std.fmt.bufPrint(&run_buf, " {d}-{d} ", .{ run_start, x - 1 })); - try out.appendSlice(arena, run_key); - run_key = try arena.dupe(u8, key); + if (std.mem.eql(u8, key, run_key[0..run_len])) continue; + emitBytes(dst, &pos, std.fmt.bufPrint(&fmt_buf, " {d}-{d} ", .{ run_start, x - 1 }) catch unreachable); + emitBytes(dst, &pos, run_key[0..run_len]); + @memcpy(run_key[0..key.len], key); + run_len = key.len; run_start = x; } - try out.append(arena, '\n'); + emitBytes(dst, &pos, "\n"); } + return pos; +} + +fn emitBytes(dst: ?[]u8, pos: *usize, bytes: []const u8) void { + if (dst) |out| @memcpy(out[pos.*..][0..bytes.len], bytes); + pos.* += bytes.len; } fn cellKey(h: *eh.Harness, x: u16, y: u16, buf: *[64]u8) []const u8 { @@ -810,29 +947,51 @@ fn buttonCode(name: []const u8) ?u16 { } fn unescape(arena: std.mem.Allocator, s: []const u8) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; + var len: usize = 0; var i: usize = 0; while (i < s.len) : (i += 1) { if (s[i] != '\\' or i + 1 >= s.len) { - try buf.append(arena, s[i]); + len += 1; continue; } i += 1; switch (s[i]) { - 'e' => try buf.append(arena, 0x1b), - 'r' => try buf.append(arena, '\r'), - 'n' => try buf.append(arena, '\n'), - 't' => try buf.append(arena, '\t'), - '\\' => try buf.append(arena, '\\'), + 'e', 'r', 'n', 't', '\\' => len += 1, 'x' => { if (i + 2 >= s.len) return error.BadEscape; - try buf.append(arena, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16)); + _ = try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16); + len += 1; i += 2; }, else => return error.BadEscape, } } - return buf.items; + const out = try arena.alloc(u8, len); + var pos: usize = 0; + i = 0; + while (i < s.len) : (i += 1) { + if (s[i] != '\\' or i + 1 >= s.len) { + out[pos] = s[i]; + pos += 1; + continue; + } + i += 1; + out[pos] = switch (s[i]) { + 'e' => 0x1b, + 'r' => '\r', + 'n' => '\n', + 't' => '\t', + '\\' => '\\', + 'x' => value: { + const value = try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16); + i += 2; + break :value value; + }, + else => unreachable, + }; + pos += 1; + } + return out; } fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8 { @@ -840,15 +999,33 @@ fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8 return try std.fmt.allocPrint(arena, "{s}/{s}", .{ cwd, p }); } -fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8, scripts: *std.ArrayList([]const u8)) !void { +fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8) ![][]const u8 { const dir_z = try arena.dupeZ(u8, dir_path); + const count = countSnapEntries(dir_z, dir_path); + const scripts = try arena.alloc([]const u8, count); + const dir = libc.opendir(dir_z) orelse fatal("no snapshots/ dir at {s}", .{dir_path}); + defer _ = libc.closedir(dir); + var i: usize = 0; + while (libc.readdir(dir)) |d| { + const name = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d.name)), 0); + if (!std.mem.endsWith(u8, name, ".snap")) continue; + if (i == scripts.len) return error.DirectoryChanged; + scripts[i] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name }); + i += 1; + } + if (i != scripts.len) return error.DirectoryChanged; + return scripts; +} + +fn countSnapEntries(dir_z: [:0]const u8, dir_path: []const u8) usize { const dir = libc.opendir(dir_z) orelse fatal("no snapshots/ dir at {s}", .{dir_path}); defer _ = libc.closedir(dir); + var count: usize = 0; while (libc.readdir(dir)) |d| { const name = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d.name)), 0); - if (std.mem.endsWith(u8, name, ".snap")) - try scripts.append(arena, try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name })); + if (std.mem.endsWith(u8, name, ".snap")) count += 1; } + return count; } fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 { @@ -856,18 +1033,27 @@ fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 { const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); - var buf: std.ArrayList(u8) = .empty; - var chunk: [16384]u8 = undefined; - while (true) { - const n = libc.read(fd, &chunk, chunk.len); + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; + const buf = try arena.alloc(u8, @intCast(end)); + var pos: usize = 0; + while (pos < buf.len) { + const n = libc.read(fd, buf[pos..].ptr, buf.len - pos); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } - if (n == 0) break; - try buf.appendSlice(arena, chunk[0..@intCast(n)]); + if (n == 0) return error.ReadFailed; + pos += @intCast(n); + } + var extra: [1]u8 = undefined; + while (true) { + const n = libc.read(fd, &extra, extra.len); + if (n < 0 and libc.errno(n) == .INTR) continue; + if (n != 0) return error.ReadFailed; + break; } - return buf.items; + return buf; } fn mkdir(arena: std.mem.Allocator, path: []const u8, exist_ok: bool) !void { -- cgit v1.3