summaryrefslogtreecommitdiff
path: root/src/limits.zig
blob: ee94a4f657e4ad67b8ab2d64f93a610a94434662 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! Every board-shaped capacity in one table.
//!
//! These numbers used to be nine `platform == .esp32p4` tests scattered across
//! nine files, each one a separate place to forget. They are not nine
//! decisions: they are ONE decision — how much memory this build is allowed to
//! spend — taken nine times, in nine files, where no reader could see the
//! total. Here the whole budget is on one screen and every cap says what it is
//! measured against.
//!
//! Two booleans derive all of it, and nothing outside this file tests the
//! platform for a capacity again.
//!
//! WHAT DOES NOT BELONG HERE: capability switches. `terminal_panes`,
//! `board_memory.enabled`, `hosted`, `font_picker` and the rest answer "does
//! this build have the thing at all", which is a question about the platform
//! and not about a budget — they stay next to the thing they gate. That
//! division is also why a build option selecting the board's budget on a
//! desktop does not work; the note on `board` below records the attempt.
const std = @import("std");
const builtin = @import("builtin");
const config = @import("pardes_config");

/// `board` is the ESP32-P4 firmware's budget: a 384 KiB heap and a 240 KiB
/// chunk of L2MEM shared between `.bss`, `.data` and the stack. `reduced` is
/// any freestanding target with no OS under it — the browser's wasm linear
/// memory grown on demand, megabytes rather than tens, but not a desktop's
/// address space.
///
/// TWO BOOLEANS AND NOT A PROFILE ENUM, and a build option was tried and
/// removed. `-Dmem-profile=board` was meant to let a native test runner
/// compile the board's capacities and boot the core under them; it does not
/// work, and cannot. The dominant term in a boot is `@sizeOf(Pane)`, which
/// carries the ghostty-vt Terminal — 1.1 MiB of it — and what removes that is
/// `pardes.terminal_panes`, a CAPABILITY keyed on the platform rather than a
/// capacity in this table. So the option shrank the rings and left the boot
/// six times over budget, producing a configuration nothing was designed for:
/// `zig build unit-test -Dmem-profile=board` deadlocked in a futex rather than
/// failing, because a hosted build with the board's effect ring silently drops
/// effects a hosted test is waiting on.
///
/// What DOES test the board's memory pressure natively is in pardes.zig: the
/// grid-scaled cost and the allocation-failure sweep, both of which are
/// platform-independent and run on the ordinary build. See the comment block
/// above `board_heap_bytes` there.
const board = config.platform == .esp32p4;
/// No OS means no address space to reserve megabytes out of, whatever the
/// platform is called. `.web` is wasm32-freestanding and `.esp32p4` is
/// riscv32-freestanding, so the target answers this for both.
const reduced_target = builtin.os.tag == .freestanding;
const KiB = 1024;
const MiB = 1024 * KiB;

/// THE NUMBER EVERY OTHER NUMBER HERE IS MEASURED AGAINST: the board's whole
/// heap, the 384 KiB chunk of L2MEM at 0x4FF40000 (`05-zig-p4`'s linker script
/// owns the split; the 128 KiB above it measured as L2 cache rather than
/// memory). Unconditional and not profile-derived, because it is a fact about
/// the silicon rather than a budget this build chose — a desktop build that
/// wants to know what the board affords is asking exactly this question, which
/// is what the memory tests in pardes.zig do with it.
pub const board_heap_bytes = 384 * KiB;

/// 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 the board 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.
pub const effect_cap = if (board) 128 else 4096;

/// Bytes of a pane's pty write that may WAIT in the core when `effect_cap`
/// chunks are already queued. `emitWrite` splits a burst into fixed 64-byte
/// effects, so without this a paste larger than `effect_cap * 64` (256 KiB on
/// a desktop) lost its tail silently — the ring refuses rather than evicts,
/// which keeps queued bytes in order but cut the new ones off. The remainder
/// parks here instead and `nextEffect` refills the ring as the host drains it,
/// so a large paste is DELAYED rather than truncated.
///
/// 4 MiB matches `tty.max_paste_bytes`, the largest burst a host can hand the
/// core in one event, so the bound is the one the producer already enforces.
/// Zero on the board: no ptys means no `emitWrite`, and the allocation this
/// would justify cannot live in 384 KiB anyway. A zero cap parks nothing and
/// restores the old refusal exactly.
pub const pending_write_cap: usize = if (board) 0 else 4 << 20;

/// 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.
pub const wrap_rows = if (board) 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.
///
/// Keyed on the profile rather than on `pardes.terminal_panes`, which this
/// file must not import (the core imports the table, not the other way round).
/// The two agree by construction: the board is the only build with no ptys.
pub const cwd_buf_cap = if (board) 0 else 1024;

/// EDIT BOUNDARIES REMEMBERED PER FILE PANE. Every entry owns a gpa copy of
/// the WHOLE file, so this number multiplies heap, not just the pane: 256 of
/// them is not a bound a 384 KiB board could ever reach anyway. `pushHistory`
/// evicts and frees the oldest once full, so the smaller ring loses the
/// deepest undo steps and nothing else — no truncation, no dropped edit.
pub const undo_max = if (board) 16 else 256;

/// How many message-row lines the session keeps for `Messages`, and one of the
/// bigger fixed costs on `Pardes`: an entry is 262 bytes, so 128 of them is
/// 32.75 KiB that is allocated whether or not anybody ever reads it. That is
/// 8.5% of the board's whole 384 KiB heap and about the size of its effect
/// ring, so the board takes sixteen — enough that a failure you looked away
/// from is still there, which is the whole point, and not enough to matter
/// beside the panes. This belongs here rather than in config.zig for exactly
/// the reason the file's header gives: it is a board-shaped capacity.
pub const message_log = if (board) 16 else 128;

/// Bounds the only user-editable, schema-owned tag fragment. It IS the storage
/// bound: `Pane.tag_tail` is `[max_tag_tail]u8`, and every writer (appendTag,
/// tagInsert, restoreDumpTail, the acmefs `tag` file) refuses input that does
/// not fit rather than truncating it, so the schema limit and the buffer can
/// never disagree — a dump reader can reject data before copying it into a
/// pane.
///
/// 512 on the P4 firmware. A tag is ONE line — a pane's path plus its command
/// words — and 4 KiB of it is 4 KiB per pane out of a 384 KiB heap. A serial
/// console is 80 columns; 512 is six of those.
pub const max_tag_tail: usize = if (board) 512 else 4096;

/// HOW LONG A HOST-SUPPLIED ABSOLUTE PATH MAY BE, and the only reason that
/// record was ever kilobytes: the shell a native host resolved, the font file
/// a native picker returned, and (in pardes.zig) the one watched theme file.
/// All three name something on a FILESYSTEM, and all three are retained
/// inline because the core has no allocator at the point they arrive.
///
/// Fixed at 4095 wherever a filesystem exists — deliberately NOT derived from
/// std.fs PATH_MAX, which web has no answer for, and 4095 rather than 4096 so
/// the macOS C bridge's NUL fits without a second, subtly different limit at
/// that boundary. Zero on the P4 firmware, which has no filesystem, no
/// processes to spawn a shell for and no font picker: `Text(0)` is a
/// zero-sized field whose `set` refuses every non-empty path, so the three
/// producers report failure instead of storing 12 KiB nothing can fill.
pub const host_path_cap: usize = if (board) 0 else 4095;

/// WHETHER PARDES'S OWN SOURCE IS EMBEDDED — the source_manifest allowlist,
/// which is a capacity spelled as rodata rather than as a number.
///
/// ON THE P4 the allowlist is EMPTY, and that is the whole difference: the
/// table is ~0.95 MiB of rodata against a 1.5 MiB flash partition, and the
/// firmware's filesystem is the serial host's, reached through the Host
/// vtable. The API is unchanged — `all` is a zero-length array and `find`
/// answers null — so every caller compiles identically and simply finds
/// nothing embedded.
pub const embedded_sources = !board;

/// Bytes per dumped row, and it is a different number on the board.
///
/// `hexdump -C`'s sixteen is the layout everyone can already read, and it needs 79 columns: ten for
/// the address, forty-eight for the hex, a gap, and the eighteen-column ASCII gutter. The P4 drives
/// a 56-column grid of which seven go to the line-number gutter, so a sixteen-byte row wraps onto a
/// second display line and the columns stop lining up - which is the entire value of the layout.
///
/// Eight fits in 46 and keeps every property that matters: address on the left, fixed-width hex
/// columns, ASCII on the right, and a gap at the halfway mark because the eye counts in fours and
/// eights rather than in sixteens.
///
/// NO `0x` ON WHAT THESE WORDS PRINT, which is where two of those columns came from. It reads no
/// worse - every number here is hex, there is no other kind, and the words refuse a decimal one - and
/// it buys something better than the width: an address in a dump can now be typed straight back into
/// a `Peek` without editing it, because bare hex is exactly what the parser wants. Output that is
/// valid input is worth more than a prefix restating what the whole file already says.
pub const hexdump_row_bytes: u32 = if (board) 8 else 16;

/// Three tiers, because the address space differs by four orders of magnitude.
/// `reduced_target` is the browser: a wasm linear memory it grows on demand, so
/// the static reservations are megabytes rather than tens.
///
/// `board` is ESP32-P4 firmware, and its tier is deliberately ALL FALLBACK. Every
/// capacity here is a `StackFallbackAllocator`'s buffer, which is a static and
/// therefore lands in `.bss` — and on the P4 `.bss`, `.data` and the stack all
/// share ONE 240 KiB chunk of L2MEM at 0x4FF03000, while the heap the fallback
/// allocator hands out is the separate 384 KiB chunk at 0x4FF40000 - the 128 KiB
/// above that measured as L2 cache rather than memory. A
/// megabyte-shaped reservation here would not fit, and every byte that did fit
/// would be taken from the stack's neighbourhood to duplicate memory the heap
/// already has. So the buffers exist only because the type requires one: 4 KiB
/// absorbs the small churn, and everything else spills to the real heap on the
/// first allocation.
pub const arena = struct {
    pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB;
    pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB;
    // Zero is legal and always spills, which is exactly what an arena for a
    // compiled-out subsystem should do. `StackFallbackAllocator(0).buffer` is
    // `[0]u8`; `get()` inits the FixedBufferAllocator over an empty slice, so
    // `FixedBufferAllocator.alloc` fails every nonzero request and `alloc`
    // falls through to `self.fallback_allocator.rawAlloc`, while `ownsPtr` over
    // an empty range is false for every pointer so `resize`/`remap`/`free`
    // route to the fallback too. See lib/std/heap.zig, StackFallbackAllocator.
    pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB;
    pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB;
    pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
};

// THE REGRESSION GUARD for the refactor that created this file: nine caps
// moved out of nine files, and the one thing that must not have changed is
// what a tty/gui/macos build gets. Spelling the historical desktop numbers
// here as literals is the point — a derivation would agree with itself.
test "board limits: a desktop build keeps exactly its historical capacities" {
    if (board or reduced_target) return error.SkipZigTest;
    try std.testing.expectEqual(4096, effect_cap);
    try std.testing.expectEqual(256, wrap_rows);
    try std.testing.expectEqual(1024, cwd_buf_cap);
    try std.testing.expectEqual(256, undo_max);
    try std.testing.expectEqual(@as(usize, 4096), max_tag_tail);
    try std.testing.expectEqual(@as(usize, 4095), host_path_cap);
    try std.testing.expect(embedded_sources);
    try std.testing.expectEqual(@as(u32, 16), hexdump_row_bytes);
    // The arena tier a desktop gets is the third one, so it is only the
    // historical desktop tier when the target is not itself reduced.
    if (reduced_target) return;
    try std.testing.expectEqual(32 * MiB, arena.pardes);
    try std.testing.expectEqual(16 * MiB, arena.frame);
    try std.testing.expectEqual(16 * MiB, arena.tree_sitter);
    try std.testing.expectEqual(32 * MiB, arena.image);
    try std.testing.expectEqual(if (config.mupdf) 64 * MiB else 64 * KiB, arena.pdf);
}