diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 13:27:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 09:47:39 -0300 |
| commit | 11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch) | |
| tree | 803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/pardes.zig | |
| parent | fbc194068687e49a8490c85c9f1257a2f2bb9079 (diff) | |
| download | pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip | |
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol
`pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns
a terminal and a socket and nothing else. N frontends on one core all look at the same screen —
`screen -x`, not N sessions.
The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per
message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the
core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is
a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin
command line, and a command line cannot carry a frame.
ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be
riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit
little-endian fixed width and no message is a blit of a native struct. A protocol that only works
between two builds of the same compiler would have thrown away the one frontend that motivated it.
## The board comes in; its toolchain stays out
`src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over-
serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves
into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so
`zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the
board from this repo's `build.zig`.
The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes
core over a serial line. What stayed is everything a second project would also want — the HAL, the
register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its
own and its `build()` early-returns when it is not the root package, so this costs the package
graph exactly zero packages and the editor's own builds nothing at all.
## limits.zig: nine forgettable places become one budget
Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions —
they are ONE decision, how much memory this build may spend, taken nine times where no reader could
see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against
what it is measured against, derived from two booleans.
The payoff is testability on a machine that is not the board: the caps are ordinary comptime values,
so a host build can be compiled against the board's numbers and the parking, eviction and clamping
paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART.
## A bare `zig build`
`zig build` with no arguments now builds the tty and GUI binaries and installs them into
`~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built
one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and
"use it" two different commands for no reason.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 361 |
1 files changed, 316 insertions, 45 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 4da64c3f..db20ff32 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -37,6 +37,9 @@ pub const pdf_pane = @import("pdf_pane.zig"); const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); const runtime_cfg = @import("runtime_config.zig"); +/// Every board-shaped capacity, in one table keyed on a profile rather than on +/// the platform. See src/limits.zig. +const limits = @import("limits.zig"); const selection_pipe = @import("selection_pipe.zig"); /// acme's control filesystem, as a pure transaction over this core: the FILES /// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The @@ -65,7 +68,7 @@ pub const frameMark = tracy.frameMark; /// `p4` is ESP32-P4 firmware: a riscv32-freestanding core whose whole host is /// a serial line. It joins `web` in having no filesystem, no ptys and no /// config directory, which is what `hosted` below is for. -pub const Platform = enum { tty, gui, web, macos, p4 }; +pub const Platform = enum { tty, gui, web, macos, esp32p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); /// Whether a theme change FADES the anchored chrome palette or replaces it. Ten display frames @@ -74,7 +77,7 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config" /// /// The exception is a screen reached through a UART. Each of the ten steps recolors every anchored /// cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times -/// over, for a fade nobody can see arrive gradually anyway. Off by default for `p4` and settable +/// over, for a fade nobody can see arrive gradually anyway. Off by default for `esp32p4` and settable /// either way from the build, because the thing that makes it wrong is the transport rather than the /// target - see `build.zig`. pub const theme_animation = @import("pardes_config").theme_animation; @@ -112,7 +115,7 @@ pub const hosted = platform == .tty or platform == .gui or platform == .macos; /// this one name, and src/term_pane.zig re-exports it as `enabled` and owns /// the whole seam — the two Pane slots included — so ghostty-vt ends up /// imported by exactly one file. -pub const terminal_panes = platform != .p4; +pub const terminal_panes = platform != .esp32p4; /// ...and the one fact about that face the core keeps: the name `Font` last /// resolved, which the Debug overlay prints. Behind the same comptime shim @@ -142,7 +145,7 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { .gui, .macos => sdl_pdf_raster_policy, // Neither hostless platform rasterizes a PDF at all (mupdf is compiled // out), so the cheaper policy is the honest placeholder. - .web, .p4 => kitty_pdf_raster_policy, + .web, .esp32p4 => kitty_pdf_raster_policy, }; // The capacities and the two heights that are STRUCTURE, not taste: the @@ -2281,40 +2284,9 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 { return out; } -const TAG_TAIL_CAP = dump.max_tag_tail; // one editable command line; extra input is refused +const TAG_TAIL_CAP = limits.max_tag_tail; // one editable command line; extra input is refused const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record -/// How many effects the ring holds. SHRUNK, not moved to the heap, on the -/// board: `pump` drains this to empty on every iteration with an -/// unconditional `while (nextEffect())` — including effects `perform` itself -/// queues — so no capacity can deadlock the drain, and the only question a -/// capacity answers is how big a single-pump BURST may be before `emit` -/// refuses the overflow. The one producer that can burst is `emitWrite`, -/// which chunks arbitrary bytes into 64-byte `.write` effects for a pty, and -/// a build with `terminal_panes == false` has no pty to write to. Everything -/// else queues O(1) effects per event, and `in_q` holds at most 64 events per -/// pump, so 128 leaves two effects per queued event. -/// -/// A 1.0625 MiB inline ring cannot live in the board's 384 KiB heap at all; -/// 128 entries is 34 KiB. NOTE THE BEHAVIOUR CHANGE: `emit` has always -/// refused (not evicted) once full, so on p4 a burst larger than 128 effects -/// now drops its tail where 4096 would have held it — reachable only through -/// `emitWrite`, i.e. only if a pty ever appears on this platform. -const EFFECT_CAP = if (platform == .p4) 128 else 4096; - -/// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane -/// taller than this (it reads the array's own length), so shrinking it cannot -/// truncate a map — a taller pane renders unwrapped, exactly as documented on -/// `Pane.wrap_line`. A serial console is not 128 rows tall. -const WRAP_ROWS = if (platform == .p4) 128 else 256; - -/// A shell's reported working directory, owned inline by the pane. Zero-sized -/// where there are no processes to report one: the `PdfSlot` rule, applied to -/// a capacity whose sole producer (`Pardes.setCwd`, fed by a pty's prompt -/// report) does not exist without terminal panes. `setOwnedCwd` clamps, so a -/// zero cap reads as "no directory known" — which is the truth here. -const CWD_BUF_CAP = if (terminal_panes) 1024 else 0; - /// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own /// default cell showing (the native-dark shape); `palette` null = let a child's /// ANSI indices reach the host untranslated until that terminal enables its @@ -2462,7 +2434,7 @@ const boot_buffer = // pinning the exact gutter width would make this test a restatement of the renderer instead of a // statement about the text. test "every line of the board's boot buffer renders whole" { - const cols: usize = @import("pardes_config").p4_cols; + const cols: usize = @import("pardes_config").esp32p4_cols; var it = std.mem.splitScalar(u8, boot_buffer, '\n'); while (it.next()) |line| { std.testing.expect(line.len + 8 <= cols) catch |err| { @@ -3085,6 +3057,133 @@ test "surface print keeps combining and wide graphemes in their display cells" { try std.testing.expectEqualStrings("A", cells[7].grapheme()); } +test "the ASCII fast path in surface print paints what the general arm paints" { + // The fast path skips a UTF-8 length, a decode, a grapheme iterator, a slice validation and a + // width lookup, so it can only be judged against those: the reference below is `print` with the + // fast-path block deleted and nothing else changed. Both routes paint into equally sized + // surfaces and every cell plus the returned column must match. + const ref = struct { + fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { + var col = x; + const end = x + w; + var i: usize = 0; + while (i < text.len) { + if (col >= end) break; + const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; + const decoded: ?u21 = if (n > 0 and i + n <= text.len) + (std.unicode.utf8Decode(text[i .. i + n]) catch null) + else + null; + var cp_slice: []const u8 = "\u{FFFD}"; + var consumed: usize = 1; + if (decoded != null) { + var git = uucode.grapheme.utf8Iterator(text[i..]); + if (git.nextGrapheme()) |g| { + const candidate = text[i .. i + g.end]; + if (std.unicode.utf8ValidateSlice(candidate)) { + cp_slice = candidate; + consumed = candidate.len; + } else { + cp_slice = text[i .. i + n]; + consumed = n; + } + } + } + i += consumed; + var cp = decoded orelse 0xFFFD; + if (cp == '\r') continue; + if (cp == '\t') { + const spaces = @min(config.tab_width, end - col); + s.fill(col, y, spaces, 1, style); + col += spaces; + continue; + } + if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { + cp = 0xFFFD; + cp_slice = "\u{FFFD}"; + } + const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1)) + 1 + else + @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); + if (width == 2 and col + 1 >= end) break; + var shown = cp_slice; + if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { + const cap = @typeInfo(@FieldType(Cell, "text")).array.len; + var prefix: usize = 0; + while (prefix < shown.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break; + if (prefix + cp_len > cap) break; + prefix += cp_len; + } + shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}"; + } + s.set(col, y, shown, style); + if (width == 2 and col + 1 < end) s.set(col + 1, y, "", style); + col += width; + } + return col; + } + }.print; + + const H = struct { + fn check(text: []const u8) !void { + // Every width from 0 past the end, because clipping interacts with the fast path: the + // wide-glyph bail at the right edge is only reachable from the general arm. + var w: u16 = 0; + while (w <= text.len + 4) : (w += 1) { + var fast_cells: [64]Cell = @splat(.{}); + var slow_cells: [64]Cell = @splat(.{}); + var fast = Surface{ .cols = fast_cells.len, .rows = 1, .cells = &fast_cells }; + var slow = Surface{ .cols = slow_cells.len, .rows = 1, .cells = &slow_cells }; + const got = fast.print(0, 0, w, text, .{}); + const want = ref(&slow, 0, 0, w, text, .{}); + std.testing.expectEqual(want, got) catch |e| { + std.debug.print("print end column: {any} w={d}\n", .{ text, w }); + return e; + }; + for (fast_cells, slow_cells, 0..) |f, sc, col| { + std.testing.expectEqualStrings(sc.grapheme(), f.grapheme()) catch |e| { + std.debug.print("print cell {d}: {any} w={d}\n", .{ col, text, w }); + return e; + }; + try std.testing.expectEqual(sc.default, f.default); + } + } + } + }; + + // The scalars that extend an ASCII base into ONE cluster - a combining mark, a ZWJ sequence, a + // spacing mark, a variation selector - are exactly what the guard on the next byte exists for. + // Wide glyphs check the two-cell accounting either side of the fast path, and the three invalid + // sequences check that a bad start byte, a truncated tail and a bad continuation each still + // become one U+FFFD per undecodable byte instead of being swallowed. + const neighbours = [_][]const u8{ + "", "x", "\u{301}", "\u{200d}\u{1f680}", + "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}", + "\u{1f642}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", + "\u{1f1e6}\u{1f1e7}", + }; + // Every byte 0x20..0x7e takes the fast path; \t, \r, the rest of the C0 controls and DEL are + // excluded by its range test and must keep the general arm's tab expansion and U+FFFD. + var buf: [16]u8 = undefined; + var b: u8 = 0; + while (b < 0x80) : (b += 1) { + buf[0] = b; + for (neighbours) |tail| { + @memcpy(buf[1..][0..tail.len], tail); + try H.check(buf[0 .. 1 + tail.len]); + @memcpy(buf[0..tail.len], tail); + buf[tail.len] = b; + try H.check(buf[0 .. tail.len + 1]); + } + } + + // Runs long enough that the fast path starts, hands over and restarts inside one call. + try H.check("ascii \u{4e16}\u{754c} e\u{301} \xff ok\t\r!"); + try H.check("plain"); +} + test "insert and normal modes edit complete Unicode graphemes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); @@ -3936,7 +4035,7 @@ pub const Pane = struct { /// link to the pane it was opened from (.inherited), or unknown (.none). /// The inherited pointer is kept valid by deferred pane teardown + fixup. cwd: Cwd = .none, - cwd_buf: [CWD_BUF_CAP]u8 = undefined, + cwd_buf: [limits.cwd_buf_cap]u8 = undefined, /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, /// or the terminal's shell rows with its edit buffer standing in). Tracks /// the shell cursor until pinned by a click or a key. @@ -3964,14 +4063,14 @@ pub const Pane = struct { /// renderPane call. Nothing else may write it; a second writer is a second /// truth, and the first click on a stale row is how you find out. /// - /// ponytail: a fixed `WRAP_ROWS` rows (256; 128 on the board). A pane + /// ponytail: a fixed `limits.wrap_rows` rows (256; 128 on the board). A pane /// taller than that does not wrap at all — bodyText leaves wrap_n at 0 and /// clips the way it always did — rather than half-recording a mapping /// every site here would then have to distrust. `wrapWidth` derives that /// refusal from `wrap_line.len` itself, so the bound follows the array. /// Grow the arrays the day a taller window turns up. - wrap_line: [WRAP_ROWS]i32 = undefined, - wrap_col: [WRAP_ROWS]i32 = undefined, + wrap_line: [limits.wrap_rows]i32 = undefined, + wrap_col: [limits.wrap_rows]i32 = undefined, wrap_n: u16 = 0, sel: [3]Sel = @splat(.{}), /// terminals only: the typed-text buffer standing in for shell rows @@ -5613,11 +5712,11 @@ pub const Pardes = struct { /// request stale as soon as another ThemeFile/Theme/NextColor command wins. custom_theme: ?Theme = null, custom_theme_active: bool = false, - /// Sized by `runtime_cfg.host_path_cap`, which is 0 where the platform has + /// Sized by `limits.host_path_cap`, which is 0 where the platform has /// no filesystem to hold a theme file: `set` then refuses every non-empty /// path and `themeFileRequest` answers `PathTooLong`, which is the honest /// answer on a board whose only IO is a UART. - theme_file_path: runtime_cfg.Text(runtime_cfg.host_path_cap) = .{}, + theme_file_path: runtime_cfg.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), @@ -5731,7 +5830,7 @@ pub const Pardes = struct { /// Pending effects, drained by the shell after each update. The bounded /// ring preserves byte order; once full, later effects are refused so no /// already-queued write can be reordered or silently evicted. - effects: [EFFECT_CAP]Effect = undefined, + effects: [limits.effect_cap]Effect = undefined, effects_head: usize = 0, effects_len: usize = 0, @@ -5809,7 +5908,7 @@ pub const Pardes = struct { p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; - } else if (comptime platform == .p4) { + } else if (comptime platform == .esp32p4) { // BARE METAL BOOTS AN EMPTY OUTPUT BUFFER, and a shell is not a layout preference // here but an impossibility: there is no operating system under this, so there is // nothing to fork and no pty to give a terminal pane. Booting one anyway produced @@ -16038,3 +16137,175 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" try std.testing.expectEqual(@as(usize, 0), rights); try std.testing.expectEqual(@as(usize, 7), lefts); } + +// THE BOARD'S MEMORY PRESSURE, REPRODUCED ON AN ORDINARY NATIVE TARGET. +// +// These live in pardes.zig and not in limits.zig because every one of them +// drives `Pardes.init`: the table alone cannot say what a boot costs. The one +// test that needs nothing but the numbers — the desktop-capacity regression +// guard — stays in src/limits.zig. +// +// All three use the FixedBufferAllocator (or the accounting FailingAllocator) +// as the CORE'S OWN gpa and hand the same allocator to every `Options` arena, +// mirroring src/esp32p4.zig's `allocators.init(a)`: on the board every tier is a +// `StackFallbackAllocator` with a 4 KiB or zero buffer, so effectively all of +// it spills onto the single heap. Calling `allocators.init` here instead would +// hand a desktop build its 32 MiB static `.bss` tier and serve every request +// out of that, making a 384 KiB budget mean nothing. + +/// The board grid. These mirror `pardes_config.esp32p4_cols/esp32p4_rows` (build.zig +/// defaults, read at src/esp32p4.zig:235) rather than reading them, because a +/// native build does not set the P4 options at all. +const board_cols: u16 = 56; +const board_rows: u16 = 14; + +fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { + return .{ + .cols = cols, + .rows = rows, + // No pty is spawned by a test host, but `tty_only` is the one-pane boot + // and therefore the closest a hosted build gets to the board's + // single-output-buffer boot. + .tty_only = true, + .frame_allocator = gpa, + .image_allocator = gpa, + .pdf_allocator = gpa, + .tree_sitter_allocator = gpa, + }; +} + +/// A boot and its teardown as one `!void` call. `checkAllAllocationFailures` +/// requires exactly that shape and `Pardes.init` returns `*Pardes` with a +/// `deinit` obligation, so the harness cannot call it directly. +fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void { + const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows)); + p.deinit(); +} + +// WHAT THE BOARD'S 384 KiB BUYS, CHECKED FROM A DESKTOP. +// +// What a hosted build CANNOT do is boot in 384 KiB, and the reason is not a +// capacity: `@sizeOf(Pane)` carries the ghostty-vt Terminal, 1.1 MiB of it, and +// what removes that on the board is `terminal_panes` — a CAPABILITY keyed on +// the platform (src/limits.zig says why it is not in the table). So there is no +// build option that turns a desktop into the board, and a test that pretended +// otherwise would be asserting a FixedBufferAllocator refuses a 2.2 MiB +// request. That was written, it asserted nothing, and it is gone. +// +// What a hosted build CAN check is every product the board's budget is spent +// on, because both factors are visible here: the board's CAPS are literals in +// src/limits.zig, and the ELEMENT SIZES are the same structs this target +// compiles (`Effect`, `Snapshot`, `Cell` and `PanelCellDiff` hold no pointers, +// so riscv32 and x86_64 agree about all four). That is the product a code +// change actually moves: nobody shrinks the board's heap, but somebody adds a +// `Buf(512)` arm to `Effect` and costs it 49 KiB it does not have. +// +// The caps are spelled as LITERALS rather than read from `limits`, because on +// this build `limits` holds the desktop numbers; these are the board's, they +// are its contract, and a derivation would agree with itself. +// +// MEASURED on x86_64-linux Debug at this commit: `@sizeOf(Effect)` 272, +// `@sizeOf(term_pane.Snapshot)` 56, `@sizeOf(Cell)` 26, `@sizeOf(PanelCellDiff)` 3 — +// so the three products are 34,816 + 1,792 + 43,120 = 79,728 bytes, a fifth of +// the heap, against bounds of 49,152 / 12,288 / 49,152 and a total of 196,608. +test "board heap: every inline ring the board pays for still fits its budget" { + const heap = limits.board_heap_bytes; + + // THE EFFECT RING, the largest single inline cost in `Pardes` — 4096 + // entries on a desktop is 1.09 MiB of the 1.14 MiB the struct occupies. + // The board holds 128 (src/limits.zig `effect_cap`). + const effect_ring = 128 * @sizeOf(Effect); + // ...and the undo history, the largest in `Pane` once the terminal is out: + // 16 snapshots on the board against 256 on a desktop. + const undo_history = 2 * 16 * @sizeOf(term_pane.Snapshot); + // ...and the three per-cell arrays the core owns at the board's own grid, + // which the next test pins the SHAPE of; this one pins the COST. + const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); + + // Each of the three separately, so a failure names the one that grew + // instead of reporting a total nobody can attribute. + try std.testing.expect(effect_ring <= heap / 8); + try std.testing.expect(undo_history <= heap / 32); + try std.testing.expect(grid <= heap / 8); + // ...and together, against the half of the heap the firmware measured as + // available after its own .bss, stack and vaxis's two grids. Three eighths + // is what the individual bounds already allow; asserting the sum as well is + // what catches two of them growing a little each. + try std.testing.expect(effect_ring + undo_history + grid <= heap / 2); +} + +// EVERY CELL IS PAID FOR FOUR TIMES on the board — vaxis's `Screen` and +// `InternalScreen`, and the core's `Surface.cells` and `presented_cells` — plus +// the core's per-cell diff classification. Two of those four are vaxis's and +// invisible from here; what this pins is the three buffers the CORE owns, so +// that adding a fourth core-owned per-cell array fails loudly instead of +// quietly costing the board another 20 KiB. +// +// MEASURED at this commit: `@sizeOf(Cell)` is 26 and `@sizeOf(PanelCellDiff)` +// is 3, so the core spends 55 bytes per cell — 43,120 bytes at the board's +// 56x14 grid, an eighth of the whole heap and the largest single grid-scaled +// cost in the program. +test "board heap: the core owns exactly three per-cell arrays" { + const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff); + + // Five NAMES, three BUFFERS: `Surface.previous_cells` and + // `Surface.cell_diffs` are views published onto the two the core owns (see + // `render`), so they cost nothing. Counted by reflection because a fifth + // name is exactly the change this test exists to catch. + const grid_slices = comptime blk: { + var n: usize = 0; + for (@typeInfo(Pardes).@"struct".fields ++ @typeInfo(Surface).@"struct".fields) |f| { + const info = @typeInfo(f.type); + if (info != .pointer or info.pointer.size != .slice) continue; + if (info.pointer.child == Cell or info.pointer.child == PanelCellDiff) n += 1; + } + break :blk n; + }; + try std.testing.expectEqual(@as(usize, 5), grid_slices); + + // The diff array is only allocated for a transition that needs the previous + // grid, so the sequence here is the shortest one that makes all three real: + // boot, acknowledge, split with `vertical` armed, render. + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + const boot = try p.render(frame.allocator()); + p.acknowledgePanelPresentation(boot.panelTracks()); + p.settings.panel_transition = .vertical; + _ = try p.newShell(1, ""); + try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + p.sync(); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + + const cells = @as(usize, p.screen_w) * p.screen_h; + try std.testing.expectEqual(cells, p.surface.cells.len); + try std.testing.expectEqual(cells, p.presented_cells.len); + try std.testing.expectEqual(cells, p.panel_cell_diffs.len); + const owned = p.surface.cells.len * @sizeOf(Cell) + + p.presented_cells.len * @sizeOf(Cell) + + p.panel_cell_diffs.len * @sizeOf(PanelCellDiff); + try std.testing.expectEqual(cells * per_cell, owned); + + // ...and the board's own grid has to leave the other seven eighths of the + // heap for everything else. 43,120 of 49,152 at the numbers above; a cell + // that grew by two bytes would spend the margin. + try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <= + limits.board_heap_bytes / 8); +} + +// EVERY ALLOCATION IN A BOOT, FAILED IN TURN. Eight of them at this commit, so +// the sweep is eight boots and costs milliseconds. `checkAllAllocationFailures` +// is the whole test because it asserts precisely the three things that matter: +// a failed allocation surfaces `error.OutOfMemory` rather than being swallowed +// into a half-built instance, `allocated_bytes == freed_bytes` at that point +// (so the failure path needs no `deinit` and leaves nothing dangling), and the +// allocation count is deterministic. +test "board heap: every allocation failure during boot is a clean OutOfMemory" { + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + bootAndTearDown, + .{ board_cols, board_rows }, + ); +} |
