summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/allocators.zig50
-rw-r--r--src/board_memory.zig34
-rw-r--r--src/builtins.zig4
-rw-r--r--src/detached/client.zig866
-rw-r--r--src/detached/server.zig1271
-rw-r--r--src/detached/wire.zig1755
-rw-r--r--src/dump.zig24
-rw-r--r--src/effect_sources.zig4
-rw-r--r--src/esp32p4.zig (renamed from src/p4.zig)32
-rw-r--r--src/esp32p4/app.zig546
-rw-r--r--src/esp32p4/input_rescue.zig271
-rw-r--r--src/esp32p4/selftest.zig351
-rw-r--r--src/esp32p4/uart.zig165
-rw-r--r--src/file_pane.zig97
-rw-r--r--src/limits.zig212
-rw-r--r--src/look.zig2
-rw-r--r--src/main.zig110
-rw-r--r--src/modal.zig121
-rw-r--r--src/nested.zig61
-rw-r--r--src/output_pane_integration_test.zig2
-rw-r--r--src/pardes.zig361
-rw-r--r--src/runtime_config.zig23
-rw-r--r--src/source_manifest.zig13
-rw-r--r--src/term_pane.zig2
-rw-r--r--src/tty/tty.zig882
25 files changed, 6953 insertions, 306 deletions
diff --git a/src/allocators.zig b/src/allocators.zig
index cf024a0b..61c55f39 100644
--- a/src/allocators.zig
+++ b/src/allocators.zig
@@ -1,43 +1,9 @@
const std = @import("std");
const builtin = @import("builtin");
-const config = @import("pardes_config");
+const limits = @import("limits.zig");
const Allocator = std.mem.Allocator;
const debug_enabled = builtin.mode == .Debug;
-/// 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.
-///
-/// `p4` 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.
-const p4 = config.platform == .p4;
-const reduced_target = config.platform == .web or builtin.os.tag == .freestanding;
-const KiB = 1024;
-const MiB = 1024 * KiB;
-
-const capacities = struct {
- const pardes = if (p4) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB;
- const frame = if (p4) 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.
- const tree_sitter = if (p4) 0 else if (reduced_target) 4 * MiB else 16 * MiB;
- const image = if (p4) 0 else if (reduced_target) 64 * KiB else 32 * MiB;
- const pdf = if (p4) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
-};
pub const Allocators = struct {
pardes: Allocator,
@@ -48,11 +14,13 @@ pub const Allocators = struct {
pdf: Allocator,
};
-var pardes_fallback: std.heap.StackFallbackAllocator(capacities.pardes) = undefined;
-var frame_fallback: std.heap.StackFallbackAllocator(capacities.frame) = undefined;
-var tree_sitter_fallback: std.heap.StackFallbackAllocator(capacities.tree_sitter) = undefined;
-var image_fallback: std.heap.StackFallbackAllocator(capacities.image) = undefined;
-var pdf_fallback: std.heap.StackFallbackAllocator(capacities.pdf) = undefined;
+/// The static reservations, one per profile tier. See `limits.arena` for why
+/// each number is what it is, and why the board's are 4 KiB and zero.
+var pardes_fallback: std.heap.StackFallbackAllocator(limits.arena.pardes) = undefined;
+var frame_fallback: std.heap.StackFallbackAllocator(limits.arena.frame) = undefined;
+var tree_sitter_fallback: std.heap.StackFallbackAllocator(limits.arena.tree_sitter) = undefined;
+var image_fallback: std.heap.StackFallbackAllocator(limits.arena.image) = undefined;
+var pdf_fallback: std.heap.StackFallbackAllocator(limits.arena.pdf) = undefined;
const Debug = std.heap.DebugAllocator(.{});
var pardes_debug: Debug = .init;
@@ -121,7 +89,7 @@ test "fixed allocators are separate, spill, and restart" {
try std.testing.expect(frame_fallback.fixed_buffer_allocator.ownsPtr(frame.ptr));
try std.testing.expect(core.ptr != frame.ptr);
- const spill = try allocs.pardes.alloc(u8, capacities.pardes + 1);
+ const spill = try allocs.pardes.alloc(u8, limits.arena.pardes + 1);
try std.testing.expect(!pardes_fallback.fixed_buffer_allocator.ownsPtr(spill.ptr));
allocs.pardes.free(spill);
allocs.frame.free(frame);
diff --git a/src/board_memory.zig b/src/board_memory.zig
index 79a9a2cf..ac567b34 100644
--- a/src/board_memory.zig
+++ b/src/board_memory.zig
@@ -7,7 +7,7 @@
//! some of them would be lying about where it is running. Under an OS the same words would be either
//! a segfault or a syscall stub, so they are absent from those builds entirely rather than present
//! and refusing. Absent means not compiled, not hidden: nothing below is analysed for a build whose
-//! platform is not `p4`.
+//! platform is not `esp32p4`.
//!
//! Everything here goes through `*allowzero volatile` pointers. A peripheral
//! register is not memory: reading UART_STATUS twice is two reads and must not
@@ -24,8 +24,9 @@ const builtin = @import("builtin");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const output_pane = @import("output_pane.zig");
+const limits = @import("limits.zig");
-/// THE ONE GATE, and it names the p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed
+/// THE ONE GATE, and it names the esp32p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed
/// and emitted for that build and for no other. Nothing in this file reaches any other target's
/// binary: not the volatile accessors, not the JP1 pinout, not the parsers.
///
@@ -41,11 +42,11 @@ const output_pane = @import("output_pane.zig");
/// The old predicate's real work was excluding wasm, which is `freestanding` too - inside the
/// browser's sandbox an address is an offset into a linear memory the engine owns, so a `Peek`
/// would read a number that means nothing about any machine and a `Poke` would corrupt the heap
-/// this same editor runs out of. Naming `p4` excludes it by construction rather than by a term
+/// this same editor runs out of. Naming `esp32p4` excludes it by construction rather than by a term
/// somebody has to keep remembering.
-pub const enabled = pardes.platform == .p4;
+pub const enabled = pardes.platform == .esp32p4;
-// The target is now the WITNESS rather than the gate: whatever else `p4` means, it has to still be
+// The target is now the WITNESS rather than the gate: whatever else `esp32p4` means, it has to still be
// a machine whose addresses are the bus's, and a hosted or wasm build reaching this line means the
// platform and the target disagree about what the firmware is.
comptime {
@@ -261,7 +262,7 @@ test "every literal is hex, with or without the prefix" {
// grid the board is actually built with, and the alignment is asserted against the column the pin
// numbers are supposed to share.
test "the pinout fits the board's own grid, in two aligned columns" {
- const cols: usize = @import("pardes_config").p4_cols;
+ const cols: usize = @import("pardes_config").esp32p4_cols;
// Seven columns of the shell's grid go to the line-number gutter before a pane's text starts.
const usable = cols - 7;
@@ -427,23 +428,10 @@ pub fn gpio(p: *Pardes, id: usize, argument: []const u8) !void {
p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable);
}
-/// 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.
-const row_bytes: u32 = if (pardes.platform == .p4) 8 else 16;
+/// Bytes per dumped row, and it is a different number on the board — see
+/// `limits.hexdump_row_bytes`, which is where that number and its reasoning
+/// live now.
+const row_bytes: u32 = limits.hexdump_row_bytes;
/// `Hexdump <addr> [len]` — len bytes, `row_bytes` to a row, hex columns and an ASCII gutter, in
/// `hexdump -C`'s layout because that is the one everyone can already read. BYTE reads, so a partial
diff --git a/src/builtins.zig b/src/builtins.zig
index 23ae18c3..2c52b5f9 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -42,7 +42,7 @@ pub const capabilities: runtime_config.Capabilities = .{
.scene_shaders = pardes.platform == .gui or pardes.platform == .macos,
// The tty's font belongs to its emulator, and the P4 firmware's belongs to
// whatever terminal is on the other end of the serial line.
- .tagline_font_size = pardes.platform != .tty and pardes.platform != .p4,
+ .tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4,
};
/// What a builtin gets to act on. One bundle rather than five parameters
@@ -922,7 +922,7 @@ pub const Lspwhy = struct {
//
// Three words gated by `board_memory.enabled`, which is a fact about the
// TARGET (freestanding, and not wasm) rather than about `pardes.platform` —
-// see the reasoning there. Today that is exactly `-Dplatform=p4`; what makes
+// see the reasoning there. Today that is exactly `-Dplatform=esp32p4`; what makes
// it the right predicate is that a second bare-metal port gets them without
// anyone remembering to add an enum arm, and the browser never does.
// Elsewhere they are absent from the command enum, the help index, the leader
diff --git a/src/detached/client.zig b/src/detached/client.zig
new file mode 100644
index 00000000..8d118782
--- /dev/null
+++ b/src/detached/client.zig
@@ -0,0 +1,866 @@
+//! THE FRONTEND SIDE of a detached session: a socket, a grid, and no core.
+//!
+//! THIS SIDE DOES NOT OWN A `Pardes`. That is the one thing to be clear about,
+//! because the shape invites the mistake: there is no `update`, no `postEvent`
+//! and no `Host` in this file. The core lives in the detached process
+//! (server.zig), which is also where every `Host.VTable` call originates. A
+//! frontend's whole job is two sentences long — send the input it collects, draw
+//! the frames it is sent — and this module is exactly that and nothing more.
+//!
+//! `Client` is NOT a renderer either. It owns `grid` and `cursor`: the cells the
+//! session is showing, kept current by applying frames as they arrive. Whoever
+//! owns a terminal (or a window, or an ESP32-P4 panel) reads those and paints.
+//! The split is deliberate — pardes already has terminal frontends, and a second
+//! one living in here would be a second convention for a job that has one.
+//!
+//! THE ATTACH IS NOT A BLOCKING HANDSHAKE. `open` connects and writes the
+//! `hello`; the `welcome` (or the `refuse`) arrives through the ordinary
+//! `wait`/`next` loop like everything else. A frontend therefore has one loop
+//! and one place it sleeps, instead of a startup path that can hang for two
+//! seconds before its terminal is even in raw mode. `attached()` says whether
+//! the session has greeted us; `refusal` says why it did not.
+//!
+//! WHAT ARRIVES, and what a frontend is expected to do with it. `next` hands
+//! back one decoded `wire.ServerMsg` at a time:
+//! * `welcome` and `frame` have already been applied to `slot`/`cols`/`rows`/
+//! `grid`/`cursor` by the time you see them. They are returned so a frontend
+//! knows the screen moved.
+//! * `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`,
+//! `watch_file`, `watch_theme`, `dump_themes` are the session asking this
+//! frontend for the real host work it has and a daemon does not: fork a
+//! shell on a real tty, put bytes on a real disk, watch a path. A frontend
+//! with none of that ignores them, exactly as a null vtable method does —
+//! and only ONE attached frontend is ever asked (server.zig's routing).
+//! * `set_clipboard`, `open_link` and `read_clipboard` are the desktop. The
+//! answer to `read_clipboard` is not a reply message: it is an ordinary
+//! `Event.paste` sent back through `send`, which is the same asynchronous
+//! shape `pull_read_clipboard` already has in-process.
+//! * `refuse` is followed by the session closing the connection, and `quit`
+//! means the session itself has ended.
+//!
+//! GEOMETRY. `cols`/`rows` are the SESSION's grid, which with several frontends
+//! attached is the smallest common one and can be smaller than this frontend's
+//! window. Tell the session about the window with `resize`; do not assume the
+//! next frame will agree with it.
+//!
+//! BORROWED BYTES. Every slice in a returned `ServerMsg` points into this
+//! client's receive buffer and is valid until the next call to `next` or
+//! `wait`. A frontend that needs a path or a payload for longer copies it — the
+//! same rule the core's own `Event.output` bytes have.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("../pardes.zig");
+const server = @import("server.zig");
+const wire = @import("wire.zig");
+
+const read_chunk = 16 * 1024;
+
+pub const Error = error{
+ /// No `$XDG_RUNTIME_DIR` and no `$HOME`, or a name that is not one path
+ /// component — there is no socket path to try.
+ NoSessionPath,
+ /// Nothing is listening there: the name is wrong, or that session ended.
+ /// Its socket file, if it is still lying about, is unlinked by the sweep the
+ /// next detached session runs.
+ NoSession,
+ /// The socket is there and this frontend will not talk to it: the directory
+ /// or the socket is not a private one of ours (server.zig `vetted`). A
+ /// planted socket at a derivable path collects every keystroke typed into
+ /// the frontend that trusts it, so this is refused rather than reported as
+ /// "no session" — the two need different answers from a human.
+ NotPrivate,
+ /// The session hung up, or the connection failed under us. Every read and
+ /// write path funnels here: a frontend's answer to all of them is the same
+ /// (report and exit), so distinguishing them would be a distinction nobody
+ /// acts on.
+ Closed,
+ /// The session said something before it said `welcome`.
+ Ungreeted,
+};
+
+pub const Client = struct {
+ gpa: std.mem.Allocator,
+ fd: c_int = -1,
+ /// Which client slot the session gave this connection. Diagnostics only,
+ /// and it exists so both sides print the same number.
+ slot: u8 = 0,
+ /// Set by a `refuse`, and the reason a frontend prints before exiting.
+ refusal: ?wire.Refusal = null,
+ /// The SESSION's grid, not this frontend's window. Zero until the welcome.
+ cols: u16 = 0,
+ rows: u16 = 0,
+ /// `cols * rows` cells: what the session is showing right now.
+ grid: std.ArrayListUnmanaged(pardes.Cell) = .empty,
+ cursor: ?wire.Cursor = null,
+ in: std.ArrayListUnmanaged(u8) = .empty,
+ out: std.ArrayListUnmanaged(u8) = .empty,
+ /// Bytes of `in` belonging to the message `next` returned last. Compacted at
+ /// the top of the next call, which is exactly what makes that message's
+ /// borrowed slices valid until then and no longer.
+ held: usize = 0,
+
+ /// Connect to the session called `name` and say hello, telling it this
+ /// frontend's window. Does not wait: the greeting arrives through `next`.
+ pub fn open(gpa: std.mem.Allocator, name: []const u8, cols: u16, rows: u16) (Error || wire.Error)!Client {
+ var path_buf: [server.path_max]u8 = undefined;
+ const path = server.sessionPath(&path_buf, name) orelse return error.NoSessionPath;
+ // Both ends vet, and this is this end's half: the session refuses a
+ // directory or a socket anyone else can reach before it binds, and until
+ // this a frontend connected to whatever it found at the path it derived.
+ // See server.zig `vetted` for what is asked and why it is asked there.
+ if (!server.vetted(path)) return error.NotPrivate;
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return error.NoSession;
+ // CLOEXEC before anything can fork, and a frontend DOES fork: the pane
+ // shells it is asked to spawn are its own children, and one of them
+ // holding this socket would keep the session believing a frontend is
+ // attached long after this process left.
+ server.setCloexec(fd);
+ if (comptime server.darwin) {
+ // linux says MSG_NOSIGNAL per write and darwin says it once per
+ // socket, which is what `nosignal` being 0 on darwin MEANS — so
+ // this call is the whole of that platform's protection and the
+ // comment on `nosignal` used to say `open` made it without `open`
+ // making it. A session that ends mid-write must not take the
+ // frontend down with SIGPIPE. server.zig `accept` is the mirror.
+ const on: c_int = 1;
+ _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int));
+ }
+ // Still blocking for the connect itself, which on AF_UNIX either lands
+ // in the listener's backlog immediately or is refused; there is no
+ // in-progress state to poll for.
+ if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ _ = libc.close(fd);
+ return error.NoSession;
+ }
+ server.setNonblock(fd);
+ var c: Client = .{ .gpa = gpa, .fd = fd };
+ errdefer c.deinit();
+ try c.send(.{ .hello = .{ .cols = cols, .rows = rows } });
+ return c;
+ }
+
+ /// Has the session greeted us? Until it has, `grid` is empty and nothing
+ /// has been drawn.
+ pub fn attached(c: *const Client) bool {
+ return c.cols != 0;
+ }
+
+ pub fn deinit(c: *Client) void {
+ if (c.fd >= 0) _ = libc.close(c.fd);
+ c.fd = -1;
+ c.grid.deinit(c.gpa);
+ c.in.deinit(c.gpa);
+ c.out.deinit(c.gpa);
+ }
+
+ /// Leave without ending the session. The `bye` is a courtesy — the session
+ /// handles a frontend that simply dies, and proving it does is what that
+ /// test is for — but it turns "the peer vanished" into "the peer left" in
+ /// the session's log, which is worth seven bytes.
+ pub fn detach(c: *Client) void {
+ c.send(.bye) catch {};
+ c.deinit();
+ }
+
+ /// One message on its way to the core. Everything a frontend collects goes
+ /// through here: keys, the mouse, pty output from the shells it forked, a
+ /// paste answering a `read_clipboard`.
+ pub fn send(c: *Client, msg: wire.ClientMsg) (Error || wire.Error)!void {
+ const want = wire.clientBound(msg);
+ c.out.ensureUnusedCapacity(c.gpa, want) catch return error.Closed;
+ const at = c.out.items.len;
+ // Encoded straight into the queue's tail rather than through a scratch
+ // buffer: a paste is four megabytes and copying it twice is two copies.
+ c.out.items.len += want;
+ const bytes = wire.encodeClient(c.out.items[at..], msg) catch |err| {
+ // AND THE QUEUE GOES BACK. `want` bytes of it are uninitialised
+ // right now, and leaving them there — which is what a bare `try`
+ // did — puts that much stack-shaped garbage on the socket at the
+ // next flush: the session decodes it as a message, refuses it, and
+ // drops a frontend whose only mistake was a message this protocol
+ // cannot carry (an `Event.command` past 64 KiB is `Overlong`, and a
+ // caller that mis-sized the queue is `NoSpace`).
+ c.out.items.len = at;
+ return err;
+ };
+ c.out.items.len = at + bytes.len;
+ return c.flush();
+ }
+
+ /// Tell the session this frontend's window changed. Not a promise about the
+ /// next frame: with other frontends attached the session grid is the
+ /// smallest common one.
+ pub fn resize(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void {
+ return c.send(.{ .event = .{ .resize = .{ .cols = cols, .rows = rows } } });
+ }
+
+ /// Wait up to `timeout_ms` for the session to say something, and push
+ /// whatever we still owe it. Zero blocks. This is the frontend's one
+ /// sleeping place FOR THE SOCKET; the terminal it draws on is polled by
+ /// whoever owns that, which is why this takes a timeout rather than a second
+ /// descriptor.
+ pub fn wait(c: *Client, timeout_ms: u32) (Error || wire.Error)!void {
+ if (c.fd < 0) return error.Closed;
+ try c.flush();
+ var fds: [1]libc.pollfd = .{.{
+ .fd = c.fd,
+ .events = if (c.out.items.len != 0) poll_in | poll_out else poll_in,
+ .revents = 0,
+ }};
+ const timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
+ if (libc.poll(&fds, 1, timeout) <= 0) return; // a timeout, or EINTR
+ if (fds[0].revents & poll_out != 0) try c.flush();
+ // POLLIN wins over POLLHUP: a session that wrote a `quit` and then
+ // closed has bytes worth reading.
+ if (fds[0].revents & poll_in != 0) return c.fill();
+ if (fds[0].revents & (poll_hup | poll_err | poll_nval) != 0) return error.Closed;
+ }
+
+ /// The next complete message, or null when the buffer holds only part of
+ /// one. `welcome` and `frame` have already been applied to this client's
+ /// own state; every slice in the result borrows the receive buffer until the
+ /// next call here or to `wait`.
+ pub fn next(c: *Client) (Error || wire.Error)!?wire.ServerMsg {
+ // Retire the message returned last, now that its borrow window is over.
+ if (c.held != 0) {
+ if (c.held == c.in.items.len) {
+ c.in.clearRetainingCapacity();
+ } else {
+ std.mem.copyForwards(u8, c.in.items, c.in.items[c.held..]);
+ c.in.items.len -= c.held;
+ }
+ c.held = 0;
+ }
+ const found = (try wire.framed(c.in.items)) orelse return null;
+ const msg = try wire.decodeServer(found.tag, found.payload);
+ c.held = found.total;
+ switch (msg) {
+ .welcome => |v| {
+ // Both sides check the version. This side checks it too because
+ // a session speaking something else may not have recognised our
+ // hello as one either, and a frontend must not paint a frame it
+ // decoded by a layout the other end does not use.
+ if (v.version != wire.version) return error.Ungreeted;
+ c.slot = v.slot;
+ try c.reshape(v.cols, v.rows);
+ },
+ .refuse => |why| c.refusal = why,
+ .frame => |f| {
+ // A frame before the greeting would be the session drawing for a
+ // connection it never accepted.
+ if (!c.attached()) return error.Ungreeted;
+ // A geometry change and a late attach are the same case on this
+ // side too: reshape, and require the FULL frame the session
+ // promises for it. Applying a diff to a grid we just cleared
+ // would leave every untouched cell blank.
+ if (f.cols != c.cols or f.rows != c.rows) {
+ if (f.kind != .full) return error.BadValue;
+ try c.reshape(f.cols, f.rows);
+ }
+ try f.apply(c.grid.items);
+ c.cursor = f.cursor;
+ },
+ // The session has ended. Left for the caller to act on, and the
+ // descriptor stays open so `deinit` is the only place that closes.
+ .quit => {},
+ else => {},
+ }
+ return msg;
+ }
+
+ // ---- internals --------------------------------------------------------
+
+ fn reshape(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void {
+ c.grid.resize(c.gpa, @as(usize, cols) * @as(usize, rows)) catch return error.Closed;
+ // Unpainted, which a frontend draws as the terminal's own default cell.
+ // The full frame that follows paints over it.
+ @memset(c.grid.items, .{});
+ c.cols = cols;
+ c.rows = rows;
+ c.cursor = null;
+ }
+
+ /// Take everything the kernel is holding, not one chunk of it. The server
+ /// deliberately reads its clients ONE chunk per round, because it is
+ /// dividing a loop between thirty-two of them; a frontend has exactly one
+ /// peer, and reading one 16 KiB slice per poll would leave it a full frame
+ /// behind on every large one — and the session drops a frontend whose queue
+ /// it cannot drain (server.zig `out_backlog`).
+ fn fill(c: *Client) (Error || wire.Error)!void {
+ var buf: [read_chunk]u8 = undefined;
+ while (true) {
+ const got = libc.read(c.fd, &buf, buf.len);
+ if (got == 0) return error.Closed;
+ if (got < 0) return switch (libc.errno(got)) {
+ .INTR => continue,
+ // Nothing more is ready; what we have is what there was.
+ .AGAIN => {},
+ else => error.Closed,
+ };
+ c.in.appendSlice(c.gpa, buf[0..@intCast(got)]) catch return error.Closed;
+ }
+ }
+
+ fn flush(c: *Client) Error!void {
+ if (c.fd < 0) return error.Closed;
+ var off: usize = 0;
+ while (off < c.out.items.len) {
+ const n = libc.send(c.fd, c.out.items.ptr + off, c.out.items.len - off, nosignal);
+ if (n < 0) switch (libc.errno(n)) {
+ .INTR => continue,
+ // The session is not draining us. The rest waits for POLLOUT;
+ // the queue is bounded in practice because a frontend's output
+ // is keystrokes and pty chunks, never frames.
+ .AGAIN => break,
+ else => return error.Closed,
+ };
+ if (n == 0) break;
+ off += @intCast(n);
+ }
+ if (off == 0) return;
+ if (off == c.out.items.len) return c.out.clearRetainingCapacity();
+ std.mem.copyForwards(u8, c.out.items, c.out.items[off..]);
+ c.out.items.len -= off;
+ }
+};
+
+// The socket primitives are server.zig's, which is the file that owns this
+// transport's conventions and both ends of it — see its `setNonblock`,
+// `nosignal` and `poll_*`. There was a third copy of all of them here.
+const nosignal = server.nosignal;
+const poll_in = server.poll_in;
+const poll_out = server.poll_out;
+const poll_hup = server.poll_hup;
+const poll_err = server.poll_err;
+const poll_nval = server.poll_nval;
+
+// ---------------------------------------------------------------------------
+// tests
+// ---------------------------------------------------------------------------
+//
+// One real core, one real unix socket, real frontends. The harness runs the
+// server's vtable in the order `Pardes.pump` runs it, so what these exercise is
+// the transport as the core actually drives it rather than a mock of it.
+
+const testing = std.testing;
+const host_api = @import("../host.zig");
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+
+/// A session on a socket of its own under `.zig-cache/tmp`, so a test never
+/// collides with a real session in `$XDG_RUNTIME_DIR` and never depends on that
+/// variable being set at all. It is process-wide, so it is saved and restored.
+const Harness = struct {
+ tmp: std.testing.TmpDir,
+ saved: ?[:0]const u8,
+ saved_buf: [4096:0]u8 = undefined,
+ core: *pardes.Pardes,
+ session: server.Session,
+ arena: std.heap.ArenaAllocator,
+ name_buf: [32]u8 = undefined,
+ name: []const u8 = &.{},
+
+ fn init(h: *Harness, cols: u16, rows: u16) !void {
+ h.tmp = std.testing.tmpDir(.{});
+ errdefer h.tmp.cleanup();
+ h.saved = if (libc.getenv("XDG_RUNTIME_DIR")) |v|
+ try std.fmt.bufPrintSentinel(&h.saved_buf, "{s}", .{std.mem.span(v)}, 0)
+ else
+ null;
+ errdefer h.restoreEnv();
+ var dir_buf: [4096:0]u8 = undefined;
+ const dir = try std.fmt.bufPrintSentinel(&dir_buf, ".zig-cache/tmp/{s}", .{h.tmp.sub_path}, 0);
+ // `ensureSocketDir` refuses anything with a bit granted to group or
+ // other, which is the whole point of it; a tmpDir arrives 0755.
+ try testing.expectEqual(@as(c_int, 0), libc.chmod(dir, 0o700));
+ _ = setenv("XDG_RUNTIME_DIR", dir.ptr, 1);
+ h.core = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = cols, .rows = rows });
+ errdefer h.core.deinit();
+ h.arena = .init(testing.allocator);
+ errdefer h.arena.deinit();
+ h.session = .{ .gpa = testing.allocator, .core = h.core, .cols = cols, .rows = rows };
+ h.name = "s";
+ try testing.expect(h.session.listen(h.name));
+ // The pre-loop drain tty.zig has, for its reason: the startup spawns are
+ // already queued and a session must not open its socket with panes that
+ // have not been created.
+ h.core.host = h.session.host();
+ while (h.core.nextEffect()) |e| h.core.perform(e);
+ }
+
+ fn restoreEnv(h: *Harness) void {
+ if (h.saved) |v| {
+ _ = setenv("XDG_RUNTIME_DIR", v.ptr, 1);
+ } else _ = unsetenv("XDG_RUNTIME_DIR");
+ }
+
+ fn deinit(h: *Harness) void {
+ h.session.deinit();
+ h.core.deinit();
+ h.arena.deinit();
+ h.restoreEnv();
+ h.tmp.cleanup();
+ }
+
+ /// `Pardes.pump`, with the one substitution a single-threaded test needs: a
+ /// bounded wait, so a frontend that says nothing cannot hang the suite
+ /// where the real session would sleep until it spoke. The queued-input
+ /// drain `pump` does between the two is absent because this host has no
+ /// queue: it calls `update` directly (borrowed bytes), and `postEvent` is
+ /// for hosts whose worker threads post from off the loop.
+ fn pump(h: *Harness) !void {
+ const host = h.session.host();
+ host.vtable.pull_wait_input.?(host.ctx, 20);
+ while (h.core.nextEffect()) |e| h.core.perform(e);
+ if (h.core.quit) return;
+ _ = h.arena.reset(.retain_capacity);
+ const surface = try h.core.render(h.arena.allocator());
+ host.vtable.push_present.?(host.ctx, surface);
+ }
+
+ /// Pump until this client has the message we are waiting for. Bounded, so a
+ /// broken transport fails a test rather than hanging the suite.
+ fn pumpUntil(h: *Harness, c: *Client, comptime want: std.meta.Tag(wire.ServerMsg)) !wire.ServerMsg {
+ for (0..64) |_| {
+ try h.pump();
+ try c.wait(5);
+ while (try c.next()) |msg| if (std.meta.activeTag(msg) == want) return msg;
+ }
+ return error.NeverArrived;
+ }
+
+ /// Pump until this client is sent a frame that CHANGES something. The first
+ /// frame after an input is not always the one carrying it — an effect the
+ /// input queued (a `Look` on a directory emits a spawn) lands a frame
+ /// later, and the frame in between legitimately says nothing.
+ fn pumpUntilChange(h: *Harness, c: *Client) !wire.Frame {
+ for (0..64) |_| {
+ const msg = try h.pumpUntil(c, .frame);
+ if (msg.frame.nruns > 0) return msg.frame;
+ }
+ return error.NothingChanged;
+ }
+
+ /// Pump until this client's grid is this shape, draining everything that
+ /// arrives. A test waits for the STATE rather than for the n-th message
+ /// because a resize is announced when the session settles it, which may be
+ /// one empty frame after the pump that caused it.
+ fn pumpUntilGrid(h: *Harness, c: *Client, cols: u16, rows: u16) !void {
+ for (0..64) |_| {
+ try h.pump();
+ try c.wait(5);
+ while (try c.next()) |_| {}
+ if (c.cols == cols and c.rows == rows) return;
+ }
+ return error.NeverResized;
+ }
+
+ /// Pump until two frontends are showing the same screen, draining both on
+ /// every pass. One pump sends every attached frontend a frame, so a test
+ /// that drains only one of them is comparing two different instants — and
+ /// what a shared session promises is that they CONVERGE, which is what this
+ /// waits for.
+ fn pumpUntilSameScreen(h: *Harness, a: *Client, b: *Client) !void {
+ for (0..64) |_| {
+ try h.pump();
+ try a.wait(5);
+ try b.wait(5);
+ while (try a.next()) |_| {}
+ while (try b.next()) |_| {}
+ if (sameScreen(a.grid.items, b.grid.items)) return;
+ }
+ return error.NeverConverged;
+ }
+
+ /// Attach a frontend and get it greeted: `open` writes the hello into the
+ /// listener's backlog, one pump accepts and answers it. Nothing blocks,
+ /// which is the whole reason the handshake is not a blocking call.
+ fn attach(h: *Harness, cols: u16, rows: u16) !Client {
+ var c = try Client.open(testing.allocator, h.name, cols, rows);
+ errdefer c.deinit();
+ _ = try h.pumpUntil(&c, .welcome);
+ return c;
+ }
+};
+
+test "detached session: a frontend attaches, is greeted, and is sent the screen" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+
+ var c = try h.attach(60, 16);
+ defer c.deinit();
+ try testing.expectEqual(@as(u8, 0), c.slot);
+ try testing.expectEqual(@as(u16, 60), c.cols);
+ try testing.expectEqual(@as(u16, 16), c.rows);
+
+ const frame = (try h.pumpUntil(&c, .frame)).frame;
+ // The first frame a frontend gets must be full: it has nothing to diff
+ // against.
+ try testing.expectEqual(wire.FrameKind.full, frame.kind);
+ try testing.expectEqual(@as(usize, 60 * 16), c.grid.items.len);
+ // ...and it must be the core's own frame, cell for cell. This is the whole
+ // claim of the transport.
+ _ = h.arena.reset(.retain_capacity);
+ const surface = try h.core.render(h.arena.allocator());
+ try expectSameScreen(surface.cells, c.grid.items);
+}
+
+test "detached session: input from a frontend reaches the core and comes back as a diff" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var c = try h.attach(60, 16);
+ defer c.deinit();
+ _ = try h.pumpUntil(&c, .frame);
+
+ // A builtin line is the cheapest input with a guaranteed visible effect,
+ // and it travels the same `Event` path a keystroke does.
+ try c.send(.{ .event = .{ .command = "Look /" } });
+ const frame = try h.pumpUntilChange(&c);
+ // The change arrived as a DIFF: this frontend was already in sync, so
+ // nothing it already had was re-sent.
+ try testing.expectEqual(wire.FrameKind.diff, frame.kind);
+ _ = h.arena.reset(.retain_capacity);
+ try expectSameScreen((try h.core.render(h.arena.allocator())).cells, c.grid.items);
+}
+
+test "detached session: two frontends share one screen at the smallest common grid" {
+ var h: Harness = undefined;
+ try h.init(80, 24);
+ defer h.deinit();
+ var a = try h.attach(80, 24);
+ defer a.deinit();
+ _ = try h.pumpUntil(&a, .frame);
+
+ // A second, smaller frontend. tmux's rule: the session shrinks to what both
+ // can show, because two people looking at different screens is the point of
+ // a shared session lost.
+ var b = try h.attach(50, 12);
+ defer b.deinit();
+ try testing.expectEqual(@as(u8, 1), b.slot);
+ try testing.expectEqual(@as(u16, 50), b.cols);
+ try testing.expectEqual(@as(u16, 12), b.rows);
+
+ // Both are now on the 50x12 grid — and getting there IS the proof that the
+ // reshape was sent as a FULL frame: `next` refuses a diff whose geometry
+ // does not match the grid it holds, so a client whose shape moved can only
+ // have been reshaped by a full one.
+ try h.pumpUntilGrid(&a, 50, 12);
+ try h.pumpUntilGrid(&b, 50, 12);
+ try testing.expectEqual(@as(usize, 50 * 12), a.grid.items.len);
+ try expectSameScreen(a.grid.items, b.grid.items);
+
+ // ...and input from EITHER moves that one screen. Both are drained on every
+ // pump before they are compared: one pump sends every attached frontend a
+ // frame, so a test that drains only one is comparing two instants.
+ try b.send(.{ .event = .{ .command = "Look /" } });
+ _ = try h.pumpUntilChange(&a);
+ try h.pumpUntilSameScreen(&a, &b);
+}
+
+test "detached session: a frontend that dies takes nothing with it" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var a = try h.attach(60, 16);
+ defer a.deinit();
+ var b = try h.attach(60, 16);
+ _ = try h.pumpUntil(&a, .frame);
+ _ = try h.pumpUntil(&b, .frame);
+ try testing.expect(h.session.clients[0].attached);
+ try testing.expect(h.session.clients[1].attached);
+
+ // Not a `bye`: the socket goes away under the session's feet, which is what
+ // a frontend crashing or being killed looks like from here.
+ b.deinit();
+ _ = try h.pumpUntil(&a, .frame);
+ try testing.expect(!h.session.clients[1].attached);
+ try testing.expectEqual(@as(c_int, -1), h.session.clients[1].fd);
+ // The survivor is still served and the core is still running.
+ try testing.expect(h.session.clients[0].attached);
+ try testing.expect(!h.core.quit);
+ try a.send(.{ .event = .{ .command = "Look /" } });
+ try testing.expect((try h.pumpUntilChange(&a)).nruns > 0);
+
+ // The freed slot takes the next frontend, and the screen comes with it.
+ var d = try h.attach(60, 16);
+ defer d.deinit();
+ try testing.expectEqual(@as(u8, 1), d.slot);
+ try testing.expectEqual(wire.FrameKind.full, (try h.pumpUntil(&d, .frame)).frame.kind);
+ try expectSameScreen(a.grid.items, d.grid.items);
+}
+
+test "detached session: a frontend speaking another protocol is refused, loudly" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+
+ // A raw socket rather than a `Client`, because the whole point is a peer
+ // that does not agree with `wire.version` — and `open` would already have
+ // sent a perfectly good hello.
+ const fd = try rawConnect(&h);
+ defer _ = libc.close(fd);
+ var buf: [64]u8 = undefined;
+ const hello = try wire.encodeClient(&buf, .{
+ .hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 },
+ });
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal));
+
+ // Two pumps: one to accept the connection, one to read the hello and
+ // answer it.
+ try h.pump();
+ try h.pump();
+ var got: [64]u8 = undefined;
+ const n = libc.read(fd, &got, got.len);
+ try testing.expect(n > 0);
+ const f = (try wire.framed(got[0..@intCast(n)])).?;
+ try testing.expectEqual(wire.Refusal.version, (try wire.decodeServer(f.tag, f.payload)).refuse);
+ // Refused means refused: the slot went back and no frame was ever sent.
+ for (&h.session.clients) |*slot| try testing.expect(!slot.attached);
+ try testing.expect(!h.core.quit);
+}
+
+test "detached session: a peer that sends garbage is dropped, not obeyed" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var c = try h.attach(60, 16);
+ defer c.deinit();
+ _ = try h.pumpUntil(&c, .frame);
+
+ // A well-formed frame around a tag this protocol has never defined. The
+ // session must close the connection rather than guess at it.
+ const junk = [_]u8{ 0xfe, 0x00, 0x00, 0x00, 0x00 };
+ try testing.expectEqual(@as(isize, junk.len), libc.send(c.fd, &junk, junk.len, nosignal));
+ for (0..8) |_| {
+ try h.pump();
+ if (!h.session.clients[0].attached) break;
+ }
+ try testing.expect(!h.session.clients[0].attached);
+ // The session is untouched: a hostile frontend costs a slot, not a session.
+ try testing.expect(!h.core.quit);
+}
+
+test "detached session: the session outlives every frontend and keeps its grid" {
+ var h: Harness = undefined;
+ try h.init(72, 20);
+ defer h.deinit();
+ {
+ var c = try h.attach(40, 10);
+ defer c.detach();
+ _ = try h.pumpUntil(&c, .frame);
+ try testing.expectEqual(@as(u16, 40), h.session.cols);
+ }
+ // Nobody attached. The grid stays where the last frontend left it rather
+ // than collapsing: a detached session is one nobody is looking at, not one
+ // of no size.
+ try h.pump();
+ try testing.expectEqual(@as(u16, 40), h.session.cols);
+ try testing.expectEqual(@as(u16, 10), h.session.rows);
+ try testing.expect(!h.core.quit);
+ // ...and the next frontend takes the grid over: with one attachment the
+ // smallest common grid IS that frontend's, so the session follows it up to
+ // 90x30 rather than pinning the departed one's 40x10 forever.
+ var again = try h.attach(90, 30);
+ defer again.deinit();
+ try testing.expectEqual(@as(u16, 90), again.cols);
+ try testing.expectEqual(@as(u16, 30), again.rows);
+ try h.pumpUntilGrid(&again, 90, 30);
+ try testing.expectEqual(@as(usize, 90 * 30), again.grid.items.len);
+}
+
+test "detached session: the seam's own routing rules, per method" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var a = try h.attach(60, 16);
+ defer a.deinit();
+ var b = try h.attach(60, 16);
+ defer b.deinit();
+ _ = try h.pumpUntil(&a, .frame);
+ _ = try h.pumpUntil(&b, .frame);
+
+ const host = h.session.host();
+ // The eight `push_` methods with one real resource behind them go to the
+ // PRIMARY only — the oldest surviving attachment — because two frontends
+ // forking a shell for one pane gives that pane two shells.
+ host.vtable.push_spawn.?(host.ctx, 1, "/tmp");
+ try expectOnly(&h, &a, &b, .spawn);
+ host.vtable.push_pty_write.?(host.ctx, 1, "ls\n");
+ try expectOnly(&h, &a, &b, .pty_write);
+ host.vtable.push_write_file.?(host.ctx, 1, "/tmp/x", "body");
+ try expectOnly(&h, &a, &b, .write_file);
+
+ // ...and the ones that are facts about the SESSION go to everybody.
+ host.vtable.push_set_clipboard.?(host.ctx, "yank");
+ try expectBoth(&h, &a, &b, .set_clipboard);
+
+ // The one pull on the wire goes to the frontend whose input caused it, and
+ // it is asked ONCE — two frontends answering would paste twice for one
+ // Ctrl-V, which is the rule host.zig states.
+ h.session.origin = 1;
+ host.vtable.pull_read_clipboard.?(host.ctx);
+ try expectOnly(&h, &b, &a, .read_clipboard);
+ h.session.origin = 0;
+ host.vtable.push_open_link.?(host.ctx, "https://x");
+ try expectOnly(&h, &a, &b, .open_link);
+}
+
+test "detached session: the client table is a refusal, not a queue" {
+ // A small grid on purpose: these thirty-two peers never read, and a full
+ // frame of 80x24 each would push them into the backlog rule that the next
+ // test is about. 20x5 keeps every queue in the kernel's own buffer.
+ var h: Harness = undefined;
+ try h.init(20, 5);
+ defer h.deinit();
+
+ var fds: [server.max_clients]c_int = @splat(-1);
+ defer for (fds) |fd| if (fd >= 0) {
+ _ = libc.close(fd);
+ };
+ var buf: [64]u8 = undefined;
+ const hello = try wire.encodeClient(&buf, .{ .hello = .{ .cols = 20, .rows = 5 } });
+ for (&fds) |*fd| {
+ fd.* = try rawConnect(&h);
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd.*, hello.ptr, hello.len, nosignal));
+ }
+ // The listener's backlog is `max_clients` deep, so this takes a few rounds:
+ // `accept` drains what is there each time it is woken.
+ for (0..16) |_| {
+ try h.pump();
+ var attached: usize = 0;
+ for (&h.session.clients) |*slot| if (slot.attached) {
+ attached += 1;
+ };
+ if (attached == server.max_clients) break;
+ }
+ for (&h.session.clients) |*slot| try testing.expect(slot.attached);
+
+ // The thirty-third is TOLD it does not fit, and told promptly: the
+ // alternative — leaving it in the backlog — makes a level-triggered poll
+ // report the listener ready forever and spins the core.
+ const extra = try rawConnect(&h);
+ defer _ = libc.close(extra);
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(extra, hello.ptr, hello.len, nosignal));
+ var got: [64]u8 = undefined;
+ const refusal = for (0..8) |_| {
+ try h.pump();
+ const n = libc.read(extra, &got, got.len);
+ if (n > 0) break got[0..@intCast(n)];
+ } else return error.NeverRefused;
+ const f = (try wire.framed(refusal)).?;
+ try testing.expectEqual(wire.Refusal.full, (try wire.decodeServer(f.tag, f.payload)).refuse);
+ // ...and the thirty-two it does serve are untouched.
+ for (&h.session.clients) |*slot| try testing.expect(slot.attached);
+ try testing.expect(!h.core.quit);
+}
+
+test "detached session: a frontend that stops reading is dropped, not waited for" {
+ var h: Harness = undefined;
+ try h.init(40, 10);
+ defer h.deinit();
+ var good = try h.attach(40, 10);
+ defer good.deinit();
+
+ // A peer that says hello and then never reads a byte again — a frontend
+ // stopped in a debugger, or one whose terminal is blocked.
+ const mute = try rawConnect(&h);
+ defer _ = libc.close(mute);
+ var buf: [64]u8 = undefined;
+ const hello = try wire.encodeClient(&buf, .{ .hello = .{ .cols = 40, .rows = 10 } });
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(mute, hello.ptr, hello.len, nosignal));
+ for (0..8) |_| {
+ try h.pump();
+ if (h.session.clients[1].attached) break;
+ }
+ try testing.expect(h.session.clients[1].attached);
+
+ // Broadcast enough control traffic to pass `out_backlog`. A clipboard
+ // mirror is the honest vehicle: it is a real `push_` that reaches every
+ // frontend and carries the yank register, so this is a session yanking a
+ // lot rather than a synthetic poke.
+ const text = try testing.allocator.alloc(u8, 256 * 1024);
+ defer testing.allocator.free(text);
+ @memset(text, 'y');
+ const host = h.session.host();
+ for (0..24) |_| {
+ if (!h.session.clients[1].attached) break;
+ host.vtable.push_set_clipboard.?(host.ctx, text);
+ try h.pump();
+ try good.wait(5);
+ while (try good.next()) |_| {}
+ }
+ // Dropped rather than queued without bound, and rather than the core
+ // blocking on it.
+ try testing.expect(!h.session.clients[1].attached);
+ try testing.expectEqual(@as(c_int, -1), h.session.clients[1].fd);
+ // The frontend that WAS reading is still attached and still being drawn
+ // for, which is the whole claim: one slow peer costs its own slot.
+ try testing.expect(h.session.clients[0].attached);
+ try testing.expect(!h.core.quit);
+ try good.send(.{ .event = .{ .command = "Msg still here" } });
+ try testing.expect((try h.pumpUntilChange(&good)).nruns > 0);
+}
+
+/// A connected socket with nothing said on it yet, for the tests whose peer is
+/// deliberately not a `Client`: one that speaks another protocol, thirty-two
+/// that fill the table, one that never reads.
+fn rawConnect(h: *Harness) !c_int {
+ var path_buf: [server.path_max]u8 = undefined;
+ const path = server.sessionPath(&path_buf, h.name).?;
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return error.NoSocket;
+ errdefer _ = libc.close(fd);
+ if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.NoSession;
+ return fd;
+}
+
+/// `want` reached `to` and nothing reached `other`.
+fn expectOnly(
+ h: *Harness,
+ to: *Client,
+ other: *Client,
+ comptime want: std.meta.Tag(wire.ServerMsg),
+) !void {
+ _ = try h.pumpUntil(to, want);
+ try other.wait(5);
+ while (try other.next()) |msg| if (std.meta.activeTag(msg) == want) {
+ std.debug.print("{t} reached a frontend it was not routed to\n", .{want});
+ return error.Misrouted;
+ };
+}
+
+fn expectBoth(
+ h: *Harness,
+ a: *Client,
+ b: *Client,
+ comptime want: std.meta.Tag(wire.ServerMsg),
+) !void {
+ _ = try h.pumpUntil(a, want);
+ _ = try h.pumpUntil(b, want);
+}
+
+/// Are these two grids showing the same thing? `visuallyEqual` and not
+/// `std.meta.eql`, because `Cell.text` past `len` is scratch the decoder does
+/// not invent — pardes.zig says in as many words that it must never
+/// manufacture a difference.
+fn sameScreen(want: []const pardes.Cell, have: []const pardes.Cell) bool {
+ if (want.len != have.len) return false;
+ for (want, have) |*x, *y| if (!x.visuallyEqual(y)) return false;
+ return true;
+}
+
+fn expectSameScreen(want: []const pardes.Cell, have: []const pardes.Cell) !void {
+ try testing.expectEqual(want.len, have.len);
+ for (want, have, 0..) |*x, *y, i| if (!x.visuallyEqual(y)) {
+ std.debug.print("cell {d} differs\n", .{i});
+ return error.CellMismatch;
+ };
+}
diff --git a/src/detached/server.zig b/src/detached/server.zig
new file mode 100644
index 00000000..6d13a4ae
--- /dev/null
+++ b/src/detached/server.zig
@@ -0,0 +1,1271 @@
+//! THE DETACHED CORE: one `Pardes` instance in a process with no terminal,
+//! serving N frontends over one unix socket.
+//!
+//! THIS SIDE OWNS THE CORE. `Session` is a `host.Host` implementation whose
+//! methods encode wire messages instead of doing IO, and whose
+//! `pull_wait_input` is a `poll(2)` over the listener and every attached
+//! frontend. The frontends own terminals and nothing else (client.zig). So the
+//! `Pardes` is here, `update` is called from here, and the same screen is on
+//! every attached frontend at once — `screen -x`, not N sessions.
+//!
+//! WHAT THIS SIDE SERVES ITSELF. Every method this vtable leaves null falls
+//! through to the core's own `host.Fallback`: the embedded source filesystem,
+//! the in-process clipboard, silent ptys. host.zig says in as many words that a
+//! zero-method host is a complete pardes, and that is exactly what a session
+//! with nothing attached is. Everything a real frontend can do BETTER — fork a
+//! shell on a real tty, put bytes on a real disk, reach a real desktop
+//! clipboard — is asked of a frontend, and the routing table below says which.
+//!
+//! ROUTING, and it is not "push means broadcast". A push reaches every HOST
+//! (host.zig's rule, which `Fanout.isPull` enforces); this is ONE host that
+//! happens to be backed by several frontends, and how it spreads a call inside
+//! itself is its own business. Three rules, one per kind of side effect:
+//! * BROADCAST — the frame, and `set_clipboard`. Every screen must show the
+//! same thing, and a yank in a shared session is a session-wide fact that
+//! every attached desktop is entitled to.
+//! * PRIMARY ONLY — `spawn`, `pty_write`, `pty_resize`, `write_file`,
+//! `write_dump`, `watch_file`, `watch_theme`, `dump_themes`. Each of these
+//! has ONE real resource behind it, and doing it twice is not doing it
+//! twice as well: two frontends forking a shell for pane 3 gives the pane
+//! two shells, and two frontends writing one path race each other. Primary
+//! is the lowest attached slot, i.e. the oldest surviving attachment — a
+//! rule that is stable while frontends come and go and needs no election.
+//! A pane's shell therefore lives in the frontend that forked it: when that
+//! frontend leaves, its panes stop producing output and the session's text,
+//! files and layout carry on. That is a real limit and it is stated here
+//! rather than papered over, because migrating a live pty between processes
+//! is a different feature.
+//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire)
+//! and `open_link`. Both answer a thing a HUMAN just did, and the answer
+//! belongs on that human's machine: the paste must come from the keyboard
+//! that asked for it, and a link must open in front of the person who
+//! clicked it. `origin` is the frontend whose event was applied most
+//! recently. Effects drain after a whole batch of events (pardes.zig
+//! `pump`), so in the rare case where two frontends type in the same
+//! millisecond the second one wins; the fallback to primary covers an
+//! effect that no input caused at all.
+//!
+//! FAIRNESS, and why no client can stall the core or another client:
+//! * every descriptor is non-blocking, and there is no thread per client. One
+//! `poll(2)` per pump covers the listener and all `max_clients` frontends.
+//! * FRAMES ARE NOT QUEUED. A client with bytes still owed to the kernel is
+//! SKIPPED for this frame and its mirror is left alone, so the next frame
+//! it does get is a diff against what it actually has. A slow frontend
+//! therefore sees fewer, larger frames instead of a growing queue, and
+//! coalescing costs no byte surgery at all.
+//! * what is left in a client's out-queue is control messages, and it is
+//! capped (`out_backlog`). The cap is checked BEFORE an append, so a single
+//! oversized message still goes out whole and what gets refused is a client
+//! that has stopped draining: it is closed. Its session and its peers are
+//! untouched, and it may reattach and be sent a full frame.
+//! * `max_clients` is a REFUSAL, not a queue — the same shape and the same
+//! number as fuse.zig's park table, and for the same reason: the listener
+//! is always accepted from even when the table is full, because a
+//! level-triggered `poll` on a backlog nobody accepts returns ready
+//! forever and spins a core. Bounded per round all the same (`accept`), and
+//! a connection that never says `hello` loses its slot
+//! (`greet_deadline_ms`) — a slot held by silence is the same denial as a
+//! queue, arrived at from the other end.
+//! * the TABLE is accounted, not just each client (`session_backlog`), and a
+//! drained client gives its buffers back (`idle_retain`): 32 slots each
+//! holding one 4 MiB paste is 128 MiB of a daemon nobody is looking at.
+//!
+//! THE SOCKET follows nested.zig's conventions exactly, and they ARE
+//! nested.zig's: `socketDir`, `ensureSocketDir`, `statNoFollow` and
+//! `setCloexec` are imported from it rather than copied, because one directory
+//! vetted by two predicates is how the two go out of step. `$XDG_RUNTIME_DIR`
+//! else `~/.local/state/pardes` created 0700 and vetted (never /tmp),
+//! `chmod 0600` before `listen(2)`, CLOEXEC on the listener and on every
+//! accepted connection. The NAME differs on purpose:
+//! `pardes-detached-<name>.sock` rather than `pardes-<pid>.sock`, so that
+//! nested.zig's sweeper — which only recognises all-digit pids — never unlinks
+//! a live detached session, and so that a person can say `--detach=work`
+//! instead of learning a pid.
+//!
+//! WHO MAY BIND A NAME, and this side is not allowed to guess. `bind(2)` on a
+//! unix socket is an atomic exclusive create, so it decides: a name whose
+//! socket ANSWERS is a live session and `listen` refuses rather than taking it
+//! (an unconditional unlink-before-bind is how a second `--detach=work` used
+//! to steal the socket out from under every frontend attached to the first).
+//! The only file this process unlinks is one it proved dead — a connect that
+//! was REFUSED — and `alive` is the single place that judgement is made, for
+//! `listen` and for the sweep both.
+//!
+//! ...and both ends do the vetting. `vetted` is the frontend's half: a socket
+//! at a path anyone could plant receives every keystroke that frontend
+//! collects, so the client checks the directory and the socket before it
+//! connects, exactly as this side checks them before it binds.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("../pardes.zig");
+const host_api = @import("../host.zig");
+const wire = @import("wire.zig");
+
+/// Diagnostics for whoever is running the daemon. Every one of these is a
+/// `debug`, and the level is not a judgement about how bad the thing is:
+/// main.zig's logFn drops this scope entirely unless PARDES_LOG is set, so what
+/// decides whether a human sees it is that variable and not the level. Reaching
+/// for `warn` instead would change exactly one thing — a TEST binary does not
+/// go through logFn, and its stderr is the build runner's failure signal.
+const log = std.log.scoped(.detached);
+
+/// nested.zig owns the socket conventions this file shares — the directory,
+/// its vetting, the stat that will not follow a symlink, CLOEXEC — and its
+/// module comment carries the reasoning for each. Imported and not copied:
+/// see the module header.
+const nested = @import("../nested.zig");
+
+/// `pub` for client.zig, which needs the same platform answer for the same
+/// reason: SIGPIPE is per-write on linux and per-socket on darwin.
+pub const darwin = nested.darwin;
+
+/// Same two ingredients as nested.zig needs, minus the ancestor walk: unix
+/// sockets and a per-user runtime directory. Anywhere else there is no detached
+/// session and `listen` says so.
+const supported = nested.supported;
+
+/// `sun_path` is 108 bytes on linux and 104 on darwin, taken from the struct so
+/// that the buffers, the fit checks and the memcpy cannot disagree with the
+/// kernel or with each other.
+const sun_path_len = nested.sun_path_len;
+
+/// How many frontends may be attached at once. The number and the shape are
+/// fuse.zig's park table: 32 slots, and overflow is a refusal rather than a
+/// queue. A session with 32 frontends on it is not a session, it is a mistake,
+/// and the 33rd gets told so instead of waiting in a backlog nobody drains.
+pub const max_clients = 32;
+
+/// Bytes of un-drained CONTROL messages a client may owe before it is closed.
+/// Frames are not in here (see the module header), so this is a backlog of
+/// ACTIONS — spawns, clipboard mirrors, file writes — and a frontend that has
+/// not taken 1 MiB of those has stopped reading its socket. Checked before an
+/// append rather than after, so one oversized message is never the thing that
+/// trips it.
+const out_backlog = 1 << 20;
+
+/// One read per client per poll round (see `receive`). 16 KiB is two orders of
+/// magnitude past a keystroke and small enough to sit on the loop's stack; a
+/// 4 MiB paste arrives across several rounds, which is the point.
+const read_chunk = 16 * 1024;
+
+/// Bytes of client traffic — every in-queue and out-queue together — this
+/// session may hold before it starts closing the peers holding it.
+/// `out_backlog` bounds ONE slot and this bounds the table, which is not the
+/// same ceiling: 32 clients each a byte under their own cap is 32 MiB of a
+/// daemon nobody is looking at. 4 MiB is one whole paste in flight plus every
+/// frame queue a real session builds, and past it the fattest peer is the peer
+/// that stopped reading. The mirrors are NOT in this number: a mirror is this
+/// session's own bookkeeping for a client it chose to serve, not something a
+/// peer can grow.
+const session_backlog = 4 << 20;
+
+/// What a DRAINED client is allowed to keep. `in` grows to hold one whole
+/// message, so a single 4 MiB paste otherwise leaves 4 MiB resident in that
+/// slot for the life of the session — 128 MiB across a full table, for
+/// something that happened once. Anything above one `read_chunk` is handed
+/// back the moment the buffer empties, and the next message pays one
+/// allocation for it; below that it is kept, so a session of keystrokes never
+/// asks the allocator at all.
+const idle_retain = read_chunk;
+
+/// How long the listener is left out of the poll set after an `accept` that
+/// failed for a reason that persists (EMFILE above all). See `accept`: the
+/// alternative was sleeping 100 ms inside the core.
+const accept_pause_ms = 100;
+
+/// Why a client's connection ended. Only ever logged (`PARDES_LOG=1`), and
+/// spelled out because "connection closed" is the one diagnostic that has never
+/// helped anybody.
+const Closed = enum { bye, peer, protocol, backlog, silent, write, read, oom, refused, quitting };
+
+const Client = struct {
+ fd: c_int = -1,
+ /// The `hello` landed and was accepted. Before that the connection exists
+ /// but votes on nothing and is sent no frames: its geometry is unknown.
+ attached: bool = false,
+ /// A `welcome` is owed, and is sent once this round's geometry has settled
+ /// so the number in it is the one the next frame will use.
+ greet: bool = false,
+ /// This frontend's own window, as its last `hello`/`resize` said. One vote
+ /// in `reconcile`'s minimum, never the session's grid by itself.
+ cols: u16 = 0,
+ rows: u16 = 0,
+ /// Bytes read and not yet a whole message.
+ in: std.ArrayListUnmanaged(u8) = .empty,
+ /// Bytes owed to the kernel.
+ out: std.ArrayListUnmanaged(u8) = .empty,
+ /// What this client's grid holds, so the next frame can be a diff. Advanced
+ /// only when a frame is actually queued for it, which is what makes a
+ /// skipped frame correct rather than lost.
+ mirror: std.ArrayListUnmanaged(pardes.Cell) = .empty,
+ /// The next frame must be full: freshly attached, or the session geometry
+ /// moved under it.
+ need_full: bool = true,
+ /// Monotonic milliseconds at `accept`, and the only thing an un-greeted
+ /// connection is timed against. See `Session.greet_deadline_ms`.
+ accepted_ms: i64 = 0,
+};
+
+/// A pane's shell: which frontend was asked to fork it, and where.
+///
+/// WHY THE SESSION REMEMBERS THIS. A spawn is the one primary-only call that
+/// has to survive having no frontend to serve it. Every boot layout creates its
+/// panes before the socket exists, so a `--detach` performs its startup spawns
+/// with nobody attached — and dropping them meant a session that opened with
+/// panes whose shells had never been forked, forever, in silence. So a spawn
+/// with no primary is OWED, and asked of whoever attaches next.
+///
+/// It is also what makes `pty_write` reach the right process. A pane's pty
+/// lives in the frontend that forked it, which is not always the primary: A
+/// attaches and forks the shells, B attaches, A leaves — the panes are re-owed
+/// to B, and then C attaching into A's freed slot becomes primary while the
+/// ptys are in B. Routing a pane's bytes by its OWNER rather than by the
+/// primary is the difference between typing into a shell and typing into
+/// nothing.
+const Shell = struct {
+ /// The slot that was asked to fork this pane's shell, or null when nobody
+ /// has been.
+ owner: ?u8 = null,
+ /// A spawn owed to whoever attaches next: either it was never asked, or the
+ /// frontend holding it left and took the pty with it.
+ owed: bool = false,
+ /// Copied, because `push_spawn`'s `cwd` borrows the core's memory for the
+ /// length of that one call and this outlives it by definition.
+ cwd: std.ArrayListUnmanaged(u8) = .empty,
+};
+
+pub const Session = struct {
+ gpa: std.mem.Allocator,
+ core: *pardes.Pardes,
+ /// -1 when nothing is bound: an unsupported platform, or a bind that
+ /// failed. A session with no listener is a session nobody can attach to,
+ /// which still runs.
+ listener: c_int = -1,
+ /// The bound path, kept so teardown unlinks exactly what was created and
+ /// nothing else — guarded on the fd, like nested.zig's `unlisten`.
+ path_buf: [sun_path_len]u8 = undefined,
+ path_len: usize = 0,
+ clients: [max_clients]Client = @splat(.{}),
+ /// The session grid: the smallest common one across attached frontends.
+ /// Seeded from the core's own startup size so the first attach of an
+ /// identically sized frontend posts no resize at all.
+ cols: u16,
+ rows: u16,
+ /// Whose input was applied last, for the two calls that must go back to one
+ /// particular frontend. See the module header.
+ origin: ?u8 = null,
+ /// One encode buffer, reused. Grown to whatever the largest message so far
+ /// needed rather than sized from `wire.max_payload`, which would be 16 MiB
+ /// of resident memory for a session whose frames are six kilobytes.
+ scratch: std.ArrayListUnmanaged(u8) = .empty,
+ /// Where each pane's shell lives, and which spawns are still owed. See
+ /// `Shell`.
+ shells: [pardes.MAX_PANES]Shell = @splat(.{}),
+ /// How long a connection may stay silent before the session takes its slot
+ /// back. `Client.open` writes its `hello` in the same call that connects,
+ /// so a peer that has said nothing for five seconds is not a frontend that
+ /// was slow, and thirty-two of them used to fill the table and lock every
+ /// real frontend out with a `refuse .full`.
+ ///
+ /// A field rather than a constant for exactly one reason: the test for that
+ /// would otherwise have to sleep five seconds. Nothing else changes it.
+ greet_deadline_ms: u32 = 5_000,
+ /// Monotonic milliseconds until which the LISTENER is left out of the poll
+ /// set, because an `accept` failed for a reason that persists. See `accept`.
+ accept_paused_ms: i64 = 0,
+
+ // ---- lifetime ---------------------------------------------------------
+
+ pub fn deinit(s: *Session) void {
+ // Tell everyone the session is over before the socket disappears, so a
+ // frontend exits on a `quit` rather than on a read error whose meaning
+ // it has to guess. Best effort by construction: these descriptors are
+ // non-blocking, so a frontend that is not reading gets the EOF instead
+ // — which is a case it has to handle regardless.
+ for (&s.clients) |*c| if (c.attached) s.send(c, .quit);
+ for (&s.clients) |*c| if (c.fd >= 0) s.close(c, .quitting);
+ s.unlisten();
+ s.scratch.deinit(s.gpa);
+ for (&s.shells) |*sh| sh.cwd.deinit(s.gpa);
+ }
+
+ /// Bind and listen. False when there is no socket, and a session without
+ /// one is simply one nobody can attach to — the same posture nested.zig
+ /// takes, and for the same reason: a failed bind must not cost a launch.
+ pub fn listen(s: *Session, name: []const u8) bool {
+ if (comptime !supported) return false;
+ var dir_buf: [sun_path_len:0]u8 = undefined;
+ const dir = nested.socketDir(&dir_buf) orelse return false;
+ if (!nested.ensureSocketDir(dir)) return false;
+ sweep(dir);
+ const path = socketPath(&s.path_buf, dir, name) orelse return false;
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return false;
+ nested.setCloexec(fd);
+ // `bind` IS the exclusive create — it fails with EADDRINUSE the moment
+ // the path exists — so it, and nothing else, decides who owns a name.
+ // There is no unlink before it: unlinking unconditionally is how a
+ // second `pardes --detach=work` took the socket away from a live
+ // session, leaving every frontend attached to a file no new frontend
+ // could reach.
+ if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ // The one case that is not a collision: a session killed rather
+ // than quit ran no teardown, so its file outlived it. `alive` is
+ // the only thing that may say so, and it says so only about a
+ // connect that was REFUSED.
+ if (alive(path)) {
+ log.debug("a detached session is already listening on {s}", .{path});
+ _ = libc.close(fd);
+ return false;
+ }
+ _ = libc.unlink(path);
+ if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ _ = libc.close(fd);
+ return false;
+ }
+ }
+ // Owner-only, and BEFORE listen(2), which is the first moment anyone
+ // could connect. The directory is already private; this is the second
+ // wall, and this socket carries keystrokes into a live editor.
+ _ = libc.chmod(path, 0o600);
+ // A backlog of max_clients: past that the kernel refuses the connect
+ // itself, which is the same answer `accept` would give.
+ if (libc.listen(fd, max_clients) != 0) {
+ _ = libc.close(fd);
+ return false;
+ }
+ setNonblock(fd);
+ s.listener = fd;
+ s.path_len = path.len;
+ return true;
+ }
+
+ fn unlisten(s: *Session) void {
+ if (s.listener < 0) return;
+ _ = libc.close(s.listener);
+ s.listener = -1;
+ // Guarded on the fd, so a bind that FAILED cannot unlink a path this
+ // process never created.
+ var z: [sun_path_len:0]u8 = undefined;
+ @memcpy(z[0..s.path_len], s.path_buf[0..s.path_len]);
+ z[s.path_len] = 0;
+ _ = libc.unlink(z[0..s.path_len :0]);
+ }
+
+ pub fn host(s: *Session) host_api.Host {
+ return .{ .ctx = s, .vtable = &vtable };
+ }
+
+ fn of(ctx: ?*anyopaque) *Session {
+ return @ptrCast(@alignCast(ctx.?));
+ }
+
+ /// Thirteen methods, and the seven that are missing are missing on purpose
+ /// — see wire.zig's header for each one's reason. `push_poll_frame` and
+ /// `push_post_present` carry no information a frame does not; the four
+ /// synchronous or dispatched pulls and `push_fs_reply` belong to whoever
+ /// owns the core, which is this process.
+ const vtable: host_api.Host.VTable = .{
+ .pull_wait_input = waitInput,
+ .push_present = present,
+ .push_spawn = spawn,
+ .push_pty_write = ptyWrite,
+ .push_pty_resize = ptyResize,
+ .push_write_file = writeFile,
+ .push_write_dump = writeDump,
+ .push_watch_file = watchFile,
+ .push_watch_theme = watchTheme,
+ .push_dump_themes = dumpThemes,
+ .push_set_clipboard = setClipboard,
+ .pull_read_clipboard = readClipboard,
+ .push_open_link = openLink,
+ };
+
+ // ---- routing ----------------------------------------------------------
+
+ /// The oldest surviving attachment. No election and no state: slots are
+ /// filled lowest-first, so the lowest attached one is the oldest that is
+ /// still here.
+ fn primary(s: *Session) ?*Client {
+ for (&s.clients) |*c| if (c.attached) return c;
+ return null;
+ }
+
+ /// ...and the frontend whose input we are answering, when there is one.
+ fn origins(s: *Session) ?*Client {
+ if (s.origin) |i| {
+ const c = &s.clients[i];
+ if (c.attached) return c;
+ }
+ return s.primary();
+ }
+
+ /// The frontend holding pane `pane`'s pty, which is NOT the primary in
+ /// general — see `Shell`. Null when no frontend holds it, which is a pane
+ /// with no child: the core's own answer to that is silence, and so is this.
+ fn holder(s: *Session, pane: u8) ?*Client {
+ if (pane >= s.shells.len) return null;
+ const owner = s.shells[pane].owner orelse return null;
+ const c = &s.clients[owner];
+ return if (c.attached) c else null;
+ }
+
+ /// Which slot this client is. From the pointer because every caller here
+ /// holds a `*Client` and not its index.
+ fn slotOf(s: *Session, c: *const Client) u8 {
+ return @intCast(@divExact(@intFromPtr(c) - @intFromPtr(&s.clients[0]), @sizeOf(Client)));
+ }
+
+ fn broadcast(s: *Session, msg: wire.ServerMsg) void {
+ for (&s.clients) |*c| if (c.attached) s.send(c, msg);
+ }
+
+ // ---- the host methods -------------------------------------------------
+
+ fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
+ const s = of(ctx);
+ if (pane >= s.shells.len) return; // the core indexes its own panes
+ const sh = &s.shells[pane];
+ // Kept whether or not there is somebody to ask, because the pane now
+ // exists either way and the cwd is the only thing that cannot be
+ // reconstructed later.
+ sh.cwd.clearRetainingCapacity();
+ sh.cwd.appendSlice(s.gpa, cwd) catch {};
+ if (s.primary()) |c| {
+ sh.owner = s.slotOf(c);
+ sh.owed = false;
+ return s.send(c, .{ .spawn = .{ .pane = pane, .cwd = cwd } });
+ }
+ // Nobody can fork a shell right now — a startup layout, or every
+ // frontend gone. NOT dropped: `reconcile` asks the next arrival.
+ sh.owner = null;
+ sh.owed = true;
+ }
+
+ fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
+ const s = of(ctx);
+ // To the frontend that forked this pane's shell, not to the primary:
+ // the pty is in that process and nowhere else. A pane whose holder is
+ // gone is silent, which is what the core does with a null method.
+ if (s.holder(pane)) |c| s.send(c, .{ .pty_write = .{ .pane = pane, .bytes = bytes } });
+ }
+
+ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
+ const s = of(ctx);
+ if (s.holder(pane)) |c| s.send(c, .{ .pty_resize = .{ .pane = pane, .cols = cols, .rows = rows } });
+ }
+
+ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
+ const s = of(ctx);
+ if (s.primary()) |c| return s.send(c, .{ .write_file = .{ .pane = pane, .path = path, .bytes = bytes } });
+ // Nobody attached, and a Put must not evaporate. This method being
+ // non-null means the core did NOT reach for its own filesystem, so the
+ // obligation a null method would have discharged is discharged here by
+ // hand — the same shape `readClipboard` below has, and host.zig's rule
+ // that a zero-method host is a complete pardes.
+ s.core.fallback.writeFile(path, bytes);
+ }
+
+ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
+ const s = of(ctx);
+ if (s.primary()) |c| return s.send(c, .{ .write_dump = bytes });
+ // ...and the same for a Dump, including the part that makes the bytes
+ // reachable again: a real host reports where it landed, which is what
+ // puts `Restore <path>` in the topbar (pardes.zig `write_dump`).
+ s.core.fallback.writeFile(pardes.fallback_dump_path, bytes);
+ s.core.setLastDump(pardes.fallback_dump_path);
+ }
+
+ fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void {
+ const s = of(ctx);
+ if (s.primary()) |c| return s.send(c, .{ .watch_file = .{ .pane = pane, .path = path, .on = on } });
+ // No frontend to watch a path, so the core's own record of what was
+ // asked is the whole of what a watch means here — exactly what a null
+ // method leaves behind.
+ if (pane < s.core.fallback.watched.len) s.core.fallback.watched[pane] = on;
+ }
+
+ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void {
+ const s = of(ctx);
+ // Dropped with nobody attached, and that is the whole of it: the core's
+ // own `theme_file` effect does nothing for a null method either, so
+ // there is no obligation left over. Same for `dump_themes` below.
+ if (s.primary()) |c| s.send(c, .{ .watch_theme = .{ .generation = generation, .on = on } });
+ }
+
+ fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
+ const s = of(ctx);
+ if (s.primary()) |c| s.send(c, .{ .dump_themes = .{ .pane = pane } });
+ }
+
+ fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
+ const s = of(ctx);
+ // Mirrored into the core's own clipboard ALWAYS, not only when nobody
+ // is attached: `readClipboard` answers from it when there is no
+ // frontend, and a frontend can leave between the yank and the paste. A
+ // yank that a detached session then pasted as the previous yank is the
+ // bug this one line is.
+ s.core.fallback.setClipboard(text);
+ s.broadcast(.{ .set_clipboard = text });
+ }
+
+ /// The one `pull_` that crosses the wire, and it stays a pull for exactly
+ /// the reason host.zig gives: two frontends answering would paste the
+ /// clipboard twice for one Ctrl-V.
+ fn readClipboard(ctx: ?*anyopaque) void {
+ const s = of(ctx);
+ if (s.origins()) |c| return s.send(c, .read_clipboard);
+ // Nobody attached. This method being non-null means the core will NOT
+ // reach for its own fallback, so an unanswered request would leave
+ // `clip_pending` armed forever — host.zig's note that a null method
+ // answers immediately is the obligation being met here by hand.
+ s.core.update(.{ .paste = s.core.fallback.clipboard.items });
+ }
+
+ fn openLink(ctx: ?*anyopaque, url: []const u8) void {
+ const s = of(ctx);
+ if (s.origins()) |c| return s.send(c, .{ .open_link = url });
+ // No desktop in reach, so the link goes where a host with no browser
+ // puts it: the core's record of the last one asked for, which is what
+ // `Fallback.setLink` is and what the acme filesystem reads back.
+ s.core.fallback.setLink(url);
+ }
+
+ // ---- the frame --------------------------------------------------------
+
+ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
+ const s = of(ctx);
+ for (&s.clients) |*c| {
+ if (!c.attached) continue;
+ // A client that has not drained what it already owes does not get
+ // this frame, and its mirror is deliberately left where it is: the
+ // next frame it does get is a diff against what it really has. A
+ // slow frontend gets fewer, larger frames rather than a queue.
+ if (c.out.items.len != 0) continue;
+ s.sendFrame(c, surface);
+ }
+ }
+
+ fn sendFrame(s: *Session, c: *Client, surface: *const pardes.Surface) void {
+ const cells = surface.cells;
+ const want = wire.frameBound(surface.cols, surface.rows);
+ s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom);
+ // Nothing comparable on the far side is the LATE JOINER and the RESIZE
+ // in one test: either way the whole grid has to be described.
+ const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len)
+ &.{}
+ else
+ c.mirror.items;
+ const cursor: ?wire.Cursor = if (surface.cursor) |cur|
+ .{ .x = cur.x, .y = cur.y, .bar = cur.bar }
+ else
+ null;
+ const bytes = wire.encodeFrame(
+ s.scratch.allocatedSlice()[0..want],
+ surface.cols,
+ surface.rows,
+ cursor,
+ cells,
+ prev,
+ ) catch |err| {
+ // A frame this protocol cannot carry is a grid past `max_cols` /
+ // `max_rows`, or a cursor the core placed outside its own surface.
+ // Dropping the frame keeps the session alive with a stale screen,
+ // which is strictly better than dropping the frontend — a frontend
+ // REFUSES such a frame and hangs up — and the log says which.
+ log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err });
+ return;
+ };
+ s.queue(c, bytes);
+ if (c.fd < 0) return; // the queue closed it; the mirror went with it
+ // The mirror advances only now, and only because the bytes are on the
+ // wire or in the kernel's buffer for it.
+ c.mirror.resize(s.gpa, cells.len) catch return s.close(c, .oom);
+ @memcpy(c.mirror.items, cells);
+ c.need_full = false;
+ }
+
+ // ---- the loop ---------------------------------------------------------
+
+ /// The only place this process sleeps, which is what `pull_wait_input`'s
+ /// comment in host.zig requires of whoever serves it. One `poll(2)` covers
+ /// the listener and every attached frontend; there is no thread per client
+ /// and nothing here blocks on a single peer.
+ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
+ const s = of(ctx);
+ // Push what the kernel will take before sleeping: a client that becomes
+ // writable while we are inside poll(2) would otherwise be a frame late,
+ // and a frame late is a frame skipped (see `present`).
+ for (&s.clients) |*c| if (c.fd >= 0) s.flush(c);
+
+ const now = monotonicMs();
+ var fds: [max_clients + 1]libc.pollfd = undefined;
+ var slots: [max_clients + 1]u8 = undefined;
+ var n: usize = 0;
+ // The listener is left OUT of the set while accepting is paused, which
+ // is how an EMFILE is waited out without the core sleeping (see
+ // `accept`). Every frontend already attached goes on being served.
+ const watching_listener = s.listener >= 0 and now >= s.accept_paused_ms;
+ if (watching_listener) {
+ fds[n] = .{ .fd = s.listener, .events = poll_in, .revents = 0 };
+ slots[n] = 0;
+ n += 1;
+ }
+ for (&s.clients, 0..) |*c, i| {
+ if (c.fd < 0) continue;
+ fds[n] = .{
+ .fd = c.fd,
+ .events = if (c.out.items.len != 0) poll_in | poll_out else poll_in,
+ .revents = 0,
+ };
+ slots[n] = @intCast(i);
+ n += 1;
+ }
+ // A detached session with no listener and no clients has no event
+ // source at all. Returning immediately would spin the outer
+ // `while (!core.quit)` at full speed, so sleep the interval the core
+ // offered and, when it offered none, a frame's worth.
+ if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms);
+ // Zero is the core's word for "sleep until something happens" (see
+ // pardes.zig `pump`: it passes a frame interval only while an animation
+ // is running). poll spells that -1.
+ var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
+ // Two things here are due on a CLOCK rather than on a descriptor: a
+ // handshake that has to expire, and a paused listener that has to come
+ // back. An indefinite poll would sit through both — and thirty-two
+ // peers that connect and then say nothing, with the session otherwise
+ // idle, IS the denial `greet_deadline_ms` exists to answer — so the
+ // wait is clamped to whichever is due first.
+ if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due);
+ const ready = libc.poll(&fds, @intCast(n), timeout);
+ // Expired unconditionally: a slot held by silence comes back on a
+ // timeout exactly as it does on a wakeup, and a poll that returned
+ // nothing is the ordinary way this deadline is reached.
+ s.expire(monotonicMs());
+ // A timeout is an ordinary frame boundary and EINTR is a signal we do
+ // not handle here; both simply come back next pump.
+ if (ready <= 0) return;
+
+ var k: usize = 0;
+ if (watching_listener) {
+ if (fds[0].revents != 0) s.accept();
+ k = 1;
+ }
+ while (k < n) : (k += 1) {
+ const c = &s.clients[slots[k]];
+ // A slot closed earlier in this same pass (its peer hung up, a
+ // decode failed, its handshake expired) must not be touched
+ // through a stale revents.
+ if (c.fd < 0) continue;
+ if (fds[k].revents & poll_out != 0) s.flush(c);
+ if (c.fd < 0) continue;
+ if (fds[k].revents & poll_in != 0) {
+ s.receive(c, slots[k]);
+ } else if (fds[k].revents & (poll_hup | poll_err | poll_nval) != 0) {
+ // POLLIN wins when both are set: a peer that wrote and then
+ // closed has bytes still worth reading.
+ s.close(c, .peer);
+ }
+ }
+ s.reconcile();
+ }
+
+ /// Milliseconds until the next deadline that is kept by the CLOCK rather
+ /// than by a descriptor, or null when there is none. Floored at zero, so a
+ /// deadline already past polls once without blocking instead of blocking
+ /// forever on a negative timeout.
+ fn nextWake(s: *const Session, now: i64) ?c_int {
+ if (now == 0) return null; // no clock; see `monotonicMs`
+ var due: ?i64 = null;
+ for (&s.clients) |*c| {
+ if (c.fd < 0 or c.attached) continue;
+ const at = c.accepted_ms + @as(i64, s.greet_deadline_ms);
+ due = if (due) |d| @min(d, at) else at;
+ }
+ if (s.listener >= 0 and s.accept_paused_ms > now)
+ due = if (due) |d| @min(d, s.accept_paused_ms) else s.accept_paused_ms;
+ const at = due orelse return null;
+ return @intCast(@max(0, @min(at - now, std.math.maxInt(c_int))));
+ }
+
+ /// Take the slots of connections that never said `hello` back. A connection
+ /// that holds a slot in silence denies a real frontend exactly as a queue
+ /// would, and `Client.open` writes its hello in the same call that
+ /// connects, so there is nothing legitimate to wait for.
+ fn expire(s: *Session, now: i64) void {
+ if (now == 0) return; // no clock: enforce nothing rather than everything
+ for (&s.clients) |*c| {
+ if (c.fd < 0 or c.attached) continue;
+ if (now - c.accepted_ms >= s.greet_deadline_ms) s.close(c, .silent);
+ }
+ }
+
+ /// Always accept, even with a full table: the tempting alternative — stop
+ /// accepting and let the kernel hold the surplus — is a spin, because
+ /// `poll` is level triggered and an unaccepted backlog reports ready
+ /// forever. fuse.zig's park table learned that as a deadlock; here it is
+ /// 100% of a core.
+ ///
+ /// BOUNDED all the same. `max_clients + 1` is enough to fill an empty table
+ /// and refuse one more, and past that the surplus waits in the backlog for
+ /// the next round — one pump later, with every frontend drawn in between.
+ /// The `while (true)` this replaces let a peer dialling in a loop hold the
+ /// core inside `accept` for as long as it kept dialling, and the core is
+ /// what draws every other frontend's screen.
+ fn accept(s: *Session) void {
+ for (0..max_clients + 1) |_| {
+ const fd = libc.accept(s.listener, null, null);
+ if (fd < 0) {
+ switch (libc.errno(fd)) {
+ // The backlog is empty, which is this loop's ordinary exit.
+ .AGAIN, .INTR, .CONNABORTED => return,
+ // Anything else — EMFILE above all — persists until some
+ // other descriptor is freed, and `poll` is LEVEL
+ // triggered: coming straight back means poll reports the
+ // listener ready again immediately and the core spins at
+ // 100% until the condition clears. The old answer was a
+ // 100 ms nanosleep, which parks the CORE — every attached
+ // frontend stops being drawn for a tenth of a second
+ // because a descriptor ran out. So the LISTENER is dropped
+ // from the poll set for that beat instead, and the session
+ // goes on serving the frontends it has.
+ else => {
+ s.accept_paused_ms = monotonicMs() + accept_pause_ms;
+ return;
+ },
+ }
+ }
+ nested.setCloexec(fd);
+ setNonblock(fd);
+ if (comptime darwin) {
+ // linux says MSG_NOSIGNAL per write; darwin says it once per
+ // socket. Either way a frontend that dies mid-frame must not
+ // take the session down with SIGPIPE.
+ const on: c_int = 1;
+ _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int));
+ }
+ const slot = for (&s.clients, 0..) |*c, i| {
+ if (c.fd < 0) break i;
+ } else {
+ // Refused, and told why, on a connection accepted purely so
+ // that the listener stays quiet.
+ s.refuseFd(fd, .full);
+ _ = libc.close(fd);
+ continue;
+ };
+ s.clients[slot] = .{ .fd = fd, .accepted_ms = monotonicMs() };
+ }
+ }
+
+ /// One read per client per round. A frontend that never stops talking gets
+ /// one turn and then the loop moves on to the others and to the frame —
+ /// which is fuse.zig's `retry` rule (one attempt per parked request per
+ /// frame) applied to sockets.
+ fn receive(s: *Session, c: *Client, slot: u8) void {
+ var buf: [read_chunk]u8 = undefined;
+ const got = libc.read(c.fd, &buf, buf.len);
+ if (got == 0) return s.close(c, .peer); // clean EOF: the frontend left
+ if (got < 0) return switch (libc.errno(got)) {
+ .INTR, .AGAIN => {},
+ else => s.close(c, .read),
+ };
+ c.in.appendSlice(s.gpa, buf[0..@intCast(got)]) catch return s.close(c, .oom);
+ // The table's own ceiling, checked where the table grows: a peer that
+ // sends the first half of a 16 MiB message and stops is holding memory
+ // no per-message check can see. See `session_backlog`.
+ s.account();
+ if (c.fd < 0) return; // it was this one
+ s.consume(c, slot);
+ }
+
+ fn consume(s: *Session, c: *Client, slot: u8) void {
+ var off: usize = 0;
+ while (true) {
+ const found = wire.framed(c.in.items[off..]) catch return s.close(c, .protocol);
+ const msg = found orelse break;
+ // The decoded Event BORROWS these bytes, so the buffer is not
+ // compacted until every message already in it has been applied —
+ // the same borrow window the tty host gives a pty chunk.
+ s.apply(c, slot, msg.tag, msg.payload) catch return s.close(c, .protocol);
+ if (c.fd < 0) return; // apply closed it, buffers and all
+ off += msg.total;
+ }
+ if (off == 0) return;
+ if (off == c.in.items.len) {
+ c.in.clearRetainingCapacity();
+ return retire(s.gpa, &c.in);
+ }
+ std.mem.copyForwards(u8, c.in.items, c.in.items[off..]);
+ c.in.items.len -= off;
+ }
+
+ fn apply(s: *Session, c: *Client, slot: u8, tag: u8, payload: []const u8) wire.Error!void {
+ var scratch: wire.Scratch = .{};
+ switch (try wire.decodeClient(tag, payload, &scratch)) {
+ .hello => |h| {
+ // A second hello on one connection is not a resize; it is a
+ // peer that is not speaking this protocol.
+ if (c.attached) return error.BadValue;
+ if (h.version != wire.version) {
+ log.debug("frontend speaks protocol {d}, this session speaks {d}", .{ h.version, wire.version });
+ return s.refuse(c, .version);
+ }
+ if (s.core.quit) return s.refuse(c, .quitting);
+ c.cols = h.cols;
+ c.rows = h.rows;
+ c.attached = true;
+ c.need_full = true;
+ // Greeted after `reconcile`, so the geometry in the welcome is
+ // the one this client's first frame will actually use.
+ c.greet = true;
+ },
+ .bye => s.close(c, .bye),
+ .event => |ev| {
+ // Input before a handshake has no geometry behind it and no
+ // version agreement either.
+ if (!c.attached) return error.BadValue;
+ switch (ev) {
+ // A frontend's resize is about ITS window. The core only
+ // ever sees the smallest common grid, which `reconcile`
+ // posts once per round when it moves — forwarding this raw
+ // would let whichever frontend resized last win.
+ .resize => |r| {
+ c.cols = r.cols;
+ c.rows = r.rows;
+ },
+ else => {
+ s.origin = slot;
+ s.core.update(ev);
+ },
+ }
+ },
+ }
+ }
+
+ /// Settle the session grid and greet whoever arrived, once per poll round
+ /// rather than once per message: three frontends attaching in the same
+ /// round are one resize, not three reflows of every pane.
+ fn reconcile(s: *Session) void {
+ var cols: u16 = 0;
+ var rows: u16 = 0;
+ for (&s.clients) |*c| {
+ if (!c.attached) continue;
+ cols = if (cols == 0) c.cols else @min(cols, c.cols);
+ rows = if (rows == 0) c.rows else @min(rows, c.rows);
+ }
+ // Nobody attached: keep the grid we had. A detached session is not a
+ // session of no size, it is one nobody is looking at, and reflowing
+ // every pane to nothing for zero readers is work with no reader.
+ if (cols != 0 and (cols != s.cols or rows != s.rows)) {
+ s.cols = cols;
+ s.rows = rows;
+ // Every mirror is now the wrong shape. `encodeFrame` reaches the
+ // same conclusion from the cell count alone, but saying it here is
+ // what makes a reshape with the SAME cell count (80x24 -> 48x40)
+ // safe too.
+ for (&s.clients) |*c| c.need_full = true;
+ s.core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ }
+ for (&s.clients, 0..) |*c, i| {
+ if (!c.greet) continue;
+ c.greet = false;
+ s.send(c, .{ .welcome = .{ .slot = @intCast(i), .cols = s.cols, .rows = s.rows } });
+ }
+ s.flushOwed();
+ }
+
+ /// Hand every owed spawn to the frontend that can serve it. Runs at the end
+ /// of a poll round, so a frontend that has just been greeted is asked for
+ /// its panes' shells in the same round it arrived — and a session that was
+ /// started with panes and no frontend (which is every `--detach`) is a
+ /// session whose panes get their shells from the first attach rather than
+ /// never. See `Shell`.
+ fn flushOwed(s: *Session) void {
+ const c = s.primary() orelse return;
+ const slot = s.slotOf(c);
+ for (&s.shells, 0..) |*sh, pane| {
+ if (!sh.owed) continue;
+ sh.owed = false;
+ sh.owner = slot;
+ s.send(c, .{ .spawn = .{ .pane = @intCast(pane), .cwd = sh.cwd.items } });
+ // The send closed it, and `close` put its panes back on the owed
+ // list; the ones this loop has not reached are still owed anyway.
+ if (c.fd < 0) return;
+ }
+ }
+
+ // ---- bytes ------------------------------------------------------------
+
+ fn send(s: *Session, c: *Client, msg: wire.ServerMsg) void {
+ const want = wire.serverBound(msg);
+ s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom);
+ const bytes = wire.encodeServer(s.scratch.allocatedSlice()[0..want], msg) catch |err| {
+ // The only reachable case is a payload past `max_payload`: a save
+ // of a pane holding more text than this protocol carries. The
+ // session keeps it (the core's own filesystem already has it) and
+ // the frontend's copy does not happen — said out loud rather than
+ // silently.
+ log.debug("message {t} not encodable: {t}", .{ msg, err });
+ return;
+ };
+ s.queue(c, bytes);
+ }
+
+ fn queue(s: *Session, c: *Client, bytes: []const u8) void {
+ // BEFORE the append, so one oversized message always goes out whole and
+ // what this refuses is a client that has stopped draining.
+ if (c.out.items.len > out_backlog) return s.close(c, .backlog);
+ // ...and the table as a whole, which `out_backlog` does not bound: 32
+ // slots one byte under it each is 32 MiB. See `session_backlog`.
+ s.account();
+ if (c.fd < 0) return; // the fattest peer was this one
+ c.out.appendSlice(s.gpa, bytes) catch return s.close(c, .oom);
+ // Try immediately: on a local socket this empties the queue in one
+ // write, and `present` skips a client whose queue is not empty.
+ s.flush(c);
+ }
+
+ /// Close the peer holding the most of the table when the table as a whole
+ /// is over `session_backlog`. One peer per call, and the fattest one,
+ /// because this is only ever asked when the total is already over and the
+ /// peer holding the most of it is the peer that stopped reading. The next
+ /// append asks again, so a second offender is closed a message later rather
+ /// than in a loop that could empty the table on one bad frame.
+ fn account(s: *Session) void {
+ var total: usize = 0;
+ var worst: ?*Client = null;
+ var worst_bytes: usize = 0;
+ for (&s.clients) |*c| {
+ if (c.fd < 0) continue;
+ const held = c.in.capacity + c.out.capacity;
+ total += held;
+ if (held > worst_bytes) {
+ worst_bytes = held;
+ worst = c;
+ }
+ }
+ if (total <= session_backlog) return;
+ if (worst) |c| s.close(c, .backlog);
+ }
+
+ fn flush(s: *Session, c: *Client) void {
+ var off: usize = 0;
+ while (off < c.out.items.len) {
+ const n = libc.send(c.fd, c.out.items.ptr + off, c.out.items.len - off, nosignal);
+ if (n < 0) switch (libc.errno(n)) {
+ .INTR => continue,
+ // The kernel's buffer is full: the rest waits for POLLOUT, and
+ // this client is skipped for frames until it drains.
+ .AGAIN => break,
+ else => return s.close(c, .write),
+ };
+ if (n == 0) break;
+ off += @intCast(n);
+ }
+ if (off == 0) return;
+ if (off == c.out.items.len) {
+ c.out.clearRetainingCapacity();
+ return retire(s.gpa, &c.out);
+ }
+ std.mem.copyForwards(u8, c.out.items, c.out.items[off..]);
+ c.out.items.len -= off;
+ }
+
+ /// Say why, then hang up. The refusal is written with a plain blocking
+ /// write on a socket nobody has sent anything on yet: it is six bytes, and
+ /// queueing it would mean keeping a slot for a connection being rejected.
+ fn refuse(s: *Session, c: *Client, why: wire.Refusal) void {
+ s.refuseFd(c.fd, why);
+ s.close(c, .refused);
+ }
+
+ /// Writes only. The descriptor belongs to the caller — `refuse` hands it to
+ /// `close`, and the full-table path in `accept` closes it itself — because
+ /// closing here as well is a double close, and the number is reusable the
+ /// instant the first one lands.
+ fn refuseFd(_: *Session, fd: c_int, why: wire.Refusal) void {
+ var buf: [wire.header_len + 1]u8 = undefined;
+ const bytes = wire.encodeServer(&buf, .{ .refuse = why }) catch unreachable;
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = libc.send(fd, bytes.ptr + off, bytes.len - off, nosignal);
+ if (n < 0 and libc.errno(n) == .INTR) continue;
+ if (n <= 0) break; // it left before hearing why; nothing to do
+ off += @intCast(n);
+ }
+ }
+
+ /// Free one slot. A frontend dying takes NOTHING with it: not the core, not
+ /// the listener, not another frontend's frames. Its buffers go back and the
+ /// slot is reusable on the next connect.
+ fn close(s: *Session, c: *Client, why: Closed) void {
+ if (c.fd < 0) return;
+ log.debug("frontend detached: {t}", .{why});
+ _ = libc.close(c.fd);
+ c.in.deinit(s.gpa);
+ c.out.deinit(s.gpa);
+ c.mirror.deinit(s.gpa);
+ const gone = s.slotOf(c);
+ // Which slot this is, so a departing frontend cannot leave `origin`
+ // pointing at it and send the next `read_clipboard` to a stranger.
+ if (s.origin) |i| if (i == gone) {
+ s.origin = null;
+ };
+ // ...and its panes' shells died with the process that forked them. They
+ // go back on the owed list, so the frontend that replaces this one is
+ // asked to fork them again in the directory they were forked in: the
+ // alternative — which is what this did — is a pane that looks alive,
+ // produces nothing, and swallows everything typed into it. Migrating a
+ // live pty between processes is the other answer and is a different
+ // feature; a fresh shell is the one this transport can keep.
+ for (&s.shells) |*sh| {
+ const owner = sh.owner orelse continue;
+ if (owner != gone) continue;
+ sh.owner = null;
+ sh.owed = true;
+ }
+ c.* = .{};
+ }
+};
+
+// ---------------------------------------------------------------------------
+// the process
+// ---------------------------------------------------------------------------
+
+/// `pardes --detach[=<name>]`: one core, no terminal, a socket. The loop is the
+/// core's own `pump`, exactly as the tty and gui shells run it — this frontend
+/// simply has no window of its own.
+///
+/// The pre-loop effect drain is here for the same reason tty.zig has one: the
+/// startup spawns are already queued, and they have to be PERFORMED before the
+/// loop rather than left in the queue. They reach no frontend — there is none
+/// yet — and are remembered instead, then asked of the first attach; `Shell`
+/// says why that is the only shape that works for a session whose panes exist
+/// before its socket does.
+pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void {
+ const gpa = init.gpa;
+ const allocs = pardes.allocators.init(gpa);
+ defer pardes.allocators.deinit();
+ var options = opts;
+ options.image_allocator = allocs.image;
+ options.pdf_allocator = allocs.pdf;
+ options.tree_sitter_allocator = allocs.tree_sitter;
+ options.frame_allocator = allocs.frame;
+
+ // The core's own subsystems, not host work: a detached session syntax
+ // highlights and decodes images exactly like an attached one.
+ pardes.image.start(init.io, allocs.image);
+ if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
+ pardes.syntax.start(allocs.tree_sitter);
+ defer {
+ pardes.image.stop();
+ if (comptime pardes.pdf_enabled) pardes.pdf.stop();
+ pardes.syntax.stop();
+ }
+
+ const core = if (options.load_path) |lp| blk: {
+ const bytes = try @import("../look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes);
+ } else try pardes.Pardes.init(allocs.pardes, options);
+ defer core.deinit();
+
+ var session: Session = .{ .gpa = gpa, .core = core, .cols = options.cols, .rows = options.rows };
+ defer session.deinit();
+ if (!session.listen(name)) {
+ // Loud, and on stderr rather than through the log: a `--detach` whose
+ // socket did not bind is a session nobody will ever find, and exiting
+ // is the only honest answer.
+ try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: could not bind a detached session socket\n");
+ return error.NoSocket;
+ }
+
+ const h = session.host();
+ core.host = h;
+ while (core.nextEffect()) |effect| core.perform(effect);
+ while (!core.quit) try core.pump(h);
+}
+
+// ---------------------------------------------------------------------------
+// the socket, nested.zig's way
+// ---------------------------------------------------------------------------
+
+/// Re-exported so the frontend half of this transport (client.zig) has ONE
+/// import for the socket conventions, and so that the file which owns the
+/// convention is the file it asks. The definition and its reasoning are
+/// nested.zig's.
+pub const setCloexec = nested.setCloexec;
+
+/// Every descriptor in this transport is non-blocking, on both sides: the core
+/// must never park on a peer (`waitInput`), and a frontend must never park on
+/// the session (client.zig `wait`). `pub` for that second caller.
+pub fn setNonblock(fd: c_int) void {
+ const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0));
+ if (flags < 0) return;
+ var o: libc.O = @bitCast(@as(u32, @bitCast(flags)));
+ o.NONBLOCK = true;
+ _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o)))));
+}
+
+/// A dead peer must never kill this process, and that is as true of a frontend
+/// whose session ended as of a session whose frontend died — so client.zig
+/// takes this one too. linux says it per write, darwin once per socket (see
+/// `accept`); the `if (darwin)` is what keeps `MSG.NOSIGNAL`, which darwin's
+/// headers do not have, out of that build.
+pub const nosignal: u32 = if (darwin) 0 else libc.MSG.NOSIGNAL;
+
+pub const poll_in: i16 = @intCast(libc.POLL.IN);
+pub const poll_out: i16 = @intCast(libc.POLL.OUT);
+pub const poll_hup: i16 = @intCast(libc.POLL.HUP);
+pub const poll_err: i16 = @intCast(libc.POLL.ERR);
+pub const poll_nval: i16 = @intCast(libc.POLL.NVAL);
+
+/// Give a drained buffer's memory back, and only a big one's: see
+/// `idle_retain`. Called where a queue empties rather than on a timer, because
+/// that is the one moment the capacity is provably unused.
+fn retire(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged(u8)) void {
+ if (list.items.len != 0 or list.capacity <= idle_retain) return;
+ list.clearAndFree(gpa);
+}
+
+/// Monotonic milliseconds, the clock macos.zig's fling already times with and
+/// for its reason: MONOTONIC and not REALTIME, because a handshake that expired
+/// because NTP stepped the wall clock backwards is a bug nobody reproduces.
+///
+/// Zero on failure, and every caller treats zero as "no clock" and enforces no
+/// deadline at all — a session that cannot read a clock keeps every slot rather
+/// than dropping every slot.
+fn monotonicMs() i64 {
+ var ts: libc.timespec = undefined;
+ if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms);
+}
+
+/// Sleep, for the one case that has no descriptor to wait on (see `waitInput`).
+fn nap(ms: u32) void {
+ var ts: libc.timespec = .{
+ .sec = @intCast(ms / 1000),
+ .nsec = @intCast((ms % 1000) * std.time.ns_per_ms),
+ };
+ _ = libc.nanosleep(&ts, null);
+}
+
+/// `<dir>/pardes-detached-<name>.sock`. The prefix differs from nested.zig's
+/// `pardes-<pid>.sock` on purpose: that file's sweeper unlinks the socket of any
+/// name whose digits name a dead pid, and a session called `work` must never
+/// look like one. The buffer is sun_path-sized, so a name that does not fit is
+/// no address at all rather than a truncated one pointing somewhere else.
+pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 {
+ // A name is one path component and nothing clever: a `/` would put the
+ // socket somewhere else entirely, and a NUL would truncate the address.
+ if (name.len == 0) return null;
+ if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null;
+ return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null;
+}
+
+const prefix = "pardes-detached-";
+
+/// The path a FRONTEND connects to for a session called `name`. Derived here
+/// rather than in client.zig because this file owns the convention, and the
+/// side that binds and the side that connects must not be able to disagree
+/// about it. `path_max` is the buffer a caller has to supply.
+pub const path_max = sun_path_len;
+
+pub fn sessionPath(buf: *[path_max]u8, name: []const u8) ?[:0]const u8 {
+ if (comptime !supported) return null;
+ var dir_buf: [sun_path_len:0]u8 = undefined;
+ const dir = nested.socketDir(&dir_buf) orelse return null;
+ return socketPath(buf, dir, name);
+}
+
+/// The FRONTEND's half of the vetting this file does before it binds, and the
+/// reason it is here rather than in client.zig: one convention, one predicate,
+/// one file that owns both.
+///
+/// Until this, the server refused a directory anyone else could write and a
+/// socket anyone else could talk to, and the client connected to whatever it
+/// found at the path it derived — which is the asymmetry this module's header
+/// condemns in as many words. A socket planted at a path a frontend derives
+/// from `$XDG_RUNTIME_DIR` receives every keystroke that frontend collects, and
+/// answers with frames of its choosing.
+///
+/// Checked and then connected, in that order, which is a TOCTOU only for
+/// somebody who can already write the directory — and the directory is the
+/// first thing this refuses.
+pub fn vetted(path: [:0]const u8) bool {
+ if (comptime !supported) return false;
+ var dir_buf: [sun_path_len:0]u8 = undefined;
+ const dir = nested.socketDir(&dir_buf) orelse return false;
+ if (!ours(nested.statNoFollow(dir) orelse return false, s_ifdir)) return false;
+ return ours(nested.statNoFollow(path) orelse return false, s_ifsock);
+}
+
+const s_ifmt: u32 = 0o170000;
+const s_ifdir: u32 = 0o040000;
+const s_ifsock: u32 = 0o140000;
+
+/// Is this a `kind` we own, with nothing granted to group or other? The three
+/// questions `nested.ensureSocketDir` asks of the directory, asked of the
+/// SOCKET too: the two walls are the directory's mode and the file's, and a
+/// frontend that checks only one of them has checked neither.
+fn ours(st: nested.DirFacts, kind: u32) bool {
+ if (st.mode & s_ifmt != kind) return false;
+ if (st.uid != libc.getuid()) return false;
+ return st.mode & 0o077 == 0;
+}
+
+/// Is something LISTENING at `path`? The one place this file decides whether a
+/// socket file is a corpse, asked by `listen` before it takes a name over and
+/// by `sweep` before it unlinks anything.
+///
+/// nested.zig can ask `kill(0)` because its filenames carry a pid; a detached
+/// session is named by a PERSON, so the question is put to the socket: a
+/// connect to a bound path with no listener is refused (ECONNREFUSED), and that
+/// refusal is the ONLY evidence of death this accepts. Everything else is life,
+/// including the case a blocking connect used to turn into a hang — a live
+/// session busy inside the core has a full backlog and answers EAGAIN, which is
+/// why this socket is NON-BLOCKING. EPERM, a socket() that failed and a path
+/// that no longer fits are all "not proven dead" too, and leave the file alone.
+///
+/// THE WINDOW THIS CANNOT SEE, stated because it is real: a session between its
+/// own `bind` and its `listen(2)` also answers ECONNREFUSED and is alive. It is
+/// two syscalls wide, it is only ever entered by another `pardes --detach`
+/// starting in the same instant, and what the loser loses is a NAME (its
+/// `listen` fails and it says so) rather than a session. Closing it needs a
+/// lock file per session, which is a second thing to leak.
+///
+/// The successful-connect case costs the live session one slot for one round:
+/// closing this descriptor immediately turns the pending connection into an
+/// EOF, which `receive` reads as a frontend that left.
+fn alive(path: [:0]const u8) bool {
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ if (path.len + 1 > addr.path.len) return true;
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return true;
+ defer _ = libc.close(fd);
+ nested.setCloexec(fd);
+ setNonblock(fd);
+ const rc = libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr)));
+ if (rc == 0) return true;
+ return libc.errno(rc) != .CONNREFUSED;
+}
+
+/// Unlink the sockets of detached sessions that are gone — our own litter,
+/// which `--attach`'s "the one session there is" would otherwise count as a
+/// session (tty.zig `sessionName`). `alive` is the whole of the judgement.
+///
+/// Bounded: one readdir of a directory only we write to, one connect each.
+fn sweep(dir: [:0]const u8) void {
+ const d = libc.opendir(dir) orelse return;
+ defer _ = libc.closedir(d);
+ while (libc.readdir(d)) |ent| {
+ const name = std.mem.sliceTo(&ent.name, 0);
+ if (!std.mem.startsWith(u8, name, prefix) or !std.mem.endsWith(u8, name, ".sock")) continue;
+ var path_buf: [sun_path_len:0]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ dir, name }, 0) catch continue;
+ if (!alive(path)) _ = libc.unlink(path);
+ }
+}
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
new file mode 100644
index 00000000..f2030e26
--- /dev/null
+++ b/src/detached/wire.zig
@@ -0,0 +1,1755 @@
+//! THE DETACHED-SESSION WIRE FORMAT: one `Event` and one `Host.VTable` call per
+//! message, byte for byte, with nothing native about the bytes.
+//!
+//! WHO OWNS THE CORE. The `Pardes` instance lives in the DETACHED process
+//! (server.zig). A frontend (client.zig) owns a terminal and a socket and
+//! nothing else: it sends the input it collects and draws the frames it is
+//! sent. One core per session, N frontends attached to it, all looking at the
+//! same screen — `screen -x`, not N sessions.
+//!
+//! WHY A CODEC AT ALL, when nested.zig's socket carries a builtin command line
+//! and has nothing to version: a command line cannot carry a frame, and frames
+//! and input are this transport's entire content.
+//!
+//! ARCHITECTURE-NEUTRAL, and not as decoration: the frontend on the other end
+//! may be riscv32-freestanding (the ESP32-P4 board) while the core is x86_64
+//! linux. So:
+//! * every integer is an explicit width, little-endian. No `usize` reaches
+//! the wire — a pointer-sized field is 4 bytes on the board and 8 here, and
+//! every field after it would then be read at the wrong offset.
+//! * no native struct is ever blitted. `@bitCast`/`std.mem.asBytes` of a Zig
+//! struct puts this compiler's field order and padding on a socket; every
+//! field below is written and read by hand.
+//! * every union and every enum gets a tag chosen HERE (`ClientTag`,
+//! `ServerTag`, `ColorTag`, ...) and never `@intFromEnum` of a core type,
+//! so reordering `Event` or `CellStyle.ul` cannot silently redefine the
+//! protocol. The mapping switches are exhaustive: adding a variant to the
+//! core is a compile error in this file, which is the point of them.
+//! * every variable-length payload carries an explicit length prefix, and
+//! `max_payload` bounds the lot. This is a parser on a socket: a malformed
+//! frame must be REFUSED, never indexed past.
+//! * a bool is one byte, 0 or 1. Any other value is a decode error rather
+//! than "nonzero is true": a byte this protocol cannot mean is evidence
+//! the stream is not the stream it claims to be.
+//! * floats travel as their IEEE-754 binary32 bit pattern inside an explicit
+//! u32. Both ends agree about binary32; neither agrees about struct layout.
+//!
+//! BUILD-NEUTRAL for the same reason. `Event.resize.cell_pixels` exists only
+//! when native PDF placement is compiled in (pardes.zig `CellPixels`), and a
+//! frontend must not have to have been built with the core's options — so it is
+//! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it.
+//!
+//! WHAT IS NOT HERE. The seam has twenty methods; this carries twelve of
+//! them, and the eight it does not are named here with their reasons.
+//! * `pull_wait_input` IS the server's poll loop, not a message.
+//! * `push_poll_frame` and `push_post_present` carry no information. They are
+//! per-frame bookkeeping ticks, and `frame` already arrives exactly once
+//! per pump at the same place in the order — a frontend does its per-frame
+//! work when a frame lands. Two more messages per frame per client would
+//! say nothing the frame does not already say.
+//! * `pull_tty_taken` and `pull_gpio_toggle` are answers the CALLER waits
+//! for, and `pull_lsp`/`pull_pipe` are work dispatched off the loop. A
+//! round trip inside `update` is the one thing this transport must never
+//! do: the core would block on a socket, and `pull_wait_input`'s own
+//! comment is that it is the only place this process may sleep. The
+//! process that owns the core answers all four.
+//! * `push_fs_reply` cannot be a broadcast. host.zig's rule is that the
+//! transport which asked is the one holding the request; with N frontends,
+//! N-1 would receive the answer to a request they never made. So the acme
+//! mount stays in the detached process, where the `Event.fs_req` that
+//! starts it is raised, and neither half of that pair is on the wire —
+//! which is also why `Event.fs_req` has no `ClientTag`.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+
+/// Bumped whenever any layout below changes. Checked on connect and refused
+/// loudly (see `Refusal.version`): two builds of pardes are routinely on one
+/// machine — `zig build` replaces the binary under a running session — and a
+/// frontend decoding another version's frame layout would paint garbage and
+/// blame the terminal.
+pub const version: u16 = 1;
+
+pub const Error = error{
+ /// The message ended inside a field.
+ Truncated,
+ /// A length prefix, a run, or a grid dimension larger than this protocol
+ /// admits. Refused before anything is allocated or indexed.
+ Overlong,
+ /// A tag byte no version of this protocol has ever defined.
+ BadTag,
+ /// A tag this protocol does define, carrying a value it cannot mean: a
+ /// 3-in-a-bool, a zero-column resize, a pane past MAX_PANES.
+ BadValue,
+ /// The payload was decoded and bytes were left over. A message that says
+ /// more than its layout has room for is not this message.
+ Trailing,
+ /// The encoder ran out of caller-supplied buffer.
+ NoSpace,
+};
+
+// ---------------------------------------------------------------------------
+// bounds
+// ---------------------------------------------------------------------------
+
+/// The largest grid this protocol carries. `Surface.cols`/`rows` are u16, so
+/// these are protocol bounds rather than type bounds, and they exist because
+/// `max_payload` below is derived from them: a decoder that accepts 65535
+/// columns accepts a 25 GiB frame prefix. A 4K display at a 6-pixel font is
+/// about 340 columns and 110 rows, so this is roughly 1.5x the largest grid
+/// any real terminal has, and the board's own is 56x14.
+pub const max_cols: u16 = 512;
+pub const max_rows: u16 = 128;
+
+/// One cell at its largest: `default` false, a 7-byte grapheme, two rgb colors,
+/// the attribute byte, the underline style and the font role. Written as the
+/// sum of the fields rather than a number so that adding a field to `Cell`
+/// moves it.
+const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1;
+
+/// `start:u32 + count:u16`. A run's cost, and therefore the break-even the
+/// encoder coalesces against (see `encodeFrame`).
+const run_header = 4 + 2;
+
+/// `kind:u8 + cols:u16 + rows:u16 + cursor(6) + nruns:u32`.
+const frame_head = 1 + 2 + 2 + 6 + 4;
+
+/// The longest legal payload, and therefore the length prefix a decoder will
+/// accept before it refuses the stream. Three messages set it:
+/// * a full frame of the largest grid, worst case one run per cell:
+/// 512*128 * (6 + 20) = 1.6 MiB.
+/// * one paste, which the tty frontend already caps at 4 MiB (tty.zig
+/// `max_paste_bytes`) on the grounds that anything larger is a mis-click.
+/// * a `write_file`, whose bytes are a pane's whole text and are the only
+/// genuinely open-ended payload here.
+/// 16 MiB is past every source file anyone edits in this editor and is still a
+/// buffer the receiving side can simply hold. A larger message is not sent and
+/// a larger prefix is not read.
+pub const max_payload: u32 = 16 << 20;
+
+/// Every message is `tag:u8, len:u32le, payload[len]`. A u32 because a full
+/// frame and a paste both pass 64 KiB; a u16 would have needed the frame split
+/// across messages, which is a second framing layer for no gain.
+pub const header_len = 5;
+
+/// Bytes `encodeFrame` may need for this grid, worst case: every cell changed,
+/// every cell in a run of its own, every cell at `cell_max`. The server sizes
+/// one buffer from this per geometry rather than guessing.
+pub fn frameBound(cols: u16, rows: u16) usize {
+ return header_len + frame_head + @as(usize, cols) * @as(usize, rows) * (run_header + cell_max);
+}
+
+// ---------------------------------------------------------------------------
+// tags
+// ---------------------------------------------------------------------------
+
+/// Frontend -> core. Exhaustive on purpose, which is the opposite of
+/// fuse.zig's `Opcode`: there, a newer KERNEL adds opcodes and a non-exhaustive
+/// enum is the only way to receive one without undefined behaviour. Here both
+/// ends are pardes and an unknown tag is not a newer peer — `version` already
+/// refused that — so it is a corrupt or hostile stream and must be rejected.
+/// `std.enums.fromInt` is how, at the one place a byte becomes a tag.
+///
+/// The numbers are the PROTOCOL's, grouped session/input rather than derived
+/// from `Event`'s declaration order, so reordering the union changes nothing.
+pub const ClientTag = enum(u8) {
+ hello = 0x01,
+ bye = 0x02,
+
+ key = 0x10,
+ mouse = 0x11,
+ resize = 0x12,
+ output = 0x13,
+ eof = 0x14,
+ lsp_resp = 0x15,
+ pipe_resp = 0x16,
+ file_changed = 0x17,
+ paste = 0x18,
+ command = 0x19,
+ pdf_scroll = 0x1a,
+ pinch = 0x1b,
+ touch_scroll = 0x1c,
+ pointer_leave = 0x1d,
+ tick = 0x1e,
+};
+
+/// Core -> frontend. 0x01..0x0f is the session, 0x10.. is one `push_` method
+/// each, in `Host.VTable`'s own order so the two lists can be read side by
+/// side.
+pub const ServerTag = enum(u8) {
+ welcome = 0x01,
+ refuse = 0x02,
+ frame = 0x03,
+ quit = 0x04,
+
+ spawn = 0x10,
+ pty_write = 0x11,
+ pty_resize = 0x12,
+ write_file = 0x13,
+ write_dump = 0x14,
+ watch_file = 0x15,
+ watch_theme = 0x16,
+ dump_themes = 0x17,
+ set_clipboard = 0x18,
+ read_clipboard = 0x19,
+ open_link = 0x1a,
+};
+
+/// Why the server hung up on a connect. Sent as a `refuse` and followed by a
+/// close, so a frontend can say something specific instead of "connection
+/// closed".
+pub const Refusal = enum(u8) {
+ /// `Hello.version` is not `version`. The frontend and the core are two
+ /// builds of pardes.
+ version = 0x01,
+ /// Every client slot is taken (see server.zig `max_clients`).
+ full = 0x02,
+ /// The core has already quit; this session is ending.
+ quitting = 0x03,
+};
+
+const ColorTag = enum(u8) { default = 0x00, index = 0x01, rgb = 0x02 };
+const UlTag = enum(u8) { off = 0x00, single = 0x01, double = 0x02, curly = 0x03, dotted = 0x04, dashed = 0x05 };
+const FontTag = enum(u8) { body = 0x00, tagline = 0x01 };
+const ButtonTag = enum(u8) {
+ left = 0x00,
+ middle = 0x01,
+ right = 0x02,
+ wheel_up = 0x03,
+ wheel_down = 0x04,
+ wheel_left = 0x05,
+ wheel_right = 0x06,
+ none = 0x07,
+};
+const KindTag = enum(u8) { press = 0x00, release = 0x01, motion = 0x02, drag = 0x03 };
+
+/// A full frame resets the receiver's grid to unpainted cells and then applies
+/// its runs; a diff applies its runs on top of what is already there. One bit
+/// of semantics and one code path, and it is what makes a full frame of a
+/// mostly-empty grid cheap.
+pub const FrameKind = enum(u8) { full = 0x01, diff = 0x02 };
+
+/// Bit per `CellStyle` bool, packed into one byte. Bit 7 is unassigned and a
+/// set bit 7 is a decode error: it is a byte this protocol cannot mean.
+const attr_bold: u8 = 1 << 0;
+const attr_dim: u8 = 1 << 1;
+const attr_italic: u8 = 1 << 2;
+const attr_blink: u8 = 1 << 3;
+const attr_reverse: u8 = 1 << 4;
+const attr_invisible: u8 = 1 << 5;
+const attr_strikethrough: u8 = 1 << 6;
+const attr_reserved: u8 = 1 << 7;
+
+// ---------------------------------------------------------------------------
+// messages
+// ---------------------------------------------------------------------------
+
+/// First message on every connection, and `version` is its first field at a
+/// fixed offset for exactly one reason: a mismatch has to be diagnosable even
+/// when the rest of the layout is the part that changed.
+pub const Hello = struct {
+ version: u16 = version,
+ /// This frontend's grid. Never zero — see `Cursor` for why zero is refused
+ /// rather than clamped.
+ cols: u16,
+ rows: u16,
+};
+
+pub const Welcome = struct {
+ version: u16 = version,
+ /// Which client slot this connection got. Carried because it is what the
+ /// server's own diagnostics name, so both sides say the same number.
+ slot: u8,
+ /// The session's grid as of this attach — the smallest common one, which
+ /// may be smaller than the `Hello` asked for. See server.zig `geometry`.
+ cols: u16,
+ rows: u16,
+};
+
+pub const Cursor = struct { x: u16, y: u16, bar: bool };
+
+/// One frame, head decoded and runs left encoded. The runs are NOT expanded
+/// into a slice of cells here: a frame of the largest grid is 1.6 MiB, this
+/// union is passed by value, and the receiver already owns the grid the runs
+/// belong in. `apply` is the bounds-checked walk.
+pub const Frame = struct {
+ kind: FrameKind,
+ cols: u16,
+ rows: u16,
+ cursor: ?Cursor,
+ nruns: u32,
+ runs: []const u8,
+
+ /// Paint this frame into `grid`, which must be exactly `cols * rows` cells
+ /// — the receiver resizes on a geometry change before applying, and a grid
+ /// of the wrong size is a receiver bug, not a wire condition.
+ ///
+ /// Every run is range-checked against the grid before a single cell is
+ /// written, so a run claiming to start past the end writes nothing.
+ pub fn apply(f: Frame, grid: []pardes.Cell) Error!void {
+ if (grid.len != @as(usize, f.cols) * @as(usize, f.rows)) return error.BadValue;
+ if (f.kind == .full) @memset(grid, .{});
+ var r: Reader = .init(f.runs);
+ var i: u32 = 0;
+ while (i < f.nruns) : (i += 1) {
+ const start = try r.getU32();
+ const count = try r.getU16();
+ // A zero-length run is not something `encodeFrame` emits, and
+ // accepting one would let a peer spend the run budget saying
+ // nothing.
+ if (count == 0) return error.BadValue;
+ // THE bounds check. Written as `count > len - start` rather than
+ // `start + count > len` because the sum of two attacker-chosen
+ // 32-bit numbers is the classic way this check is bypassed.
+ if (start > grid.len or count > grid.len - start) return error.Overlong;
+ for (grid[start..][0..count]) |*c| c.* = try decodeCell(&r);
+ }
+ try r.end();
+ }
+};
+
+/// Frontend -> core, decoded. The `Event`'s slices BORROW the payload buffer,
+/// exactly like the pty chunks the tty host hands to `update`: valid for that
+/// one call and no longer.
+pub const ClientMsg = union(enum) {
+ hello: Hello,
+ bye,
+ event: pardes.Event,
+};
+
+/// Core -> frontend, decoded. Slices borrow the payload buffer the same way.
+pub const ServerMsg = union(enum) {
+ welcome: Welcome,
+ refuse: Refusal,
+ frame: Frame,
+ /// The session is over. Sent before the listener closes so a frontend can
+ /// exit rather than report a broken pipe.
+ quit,
+
+ spawn: struct { pane: u8, cwd: []const u8 },
+ pty_write: struct { pane: u8, bytes: []const u8 },
+ pty_resize: struct { pane: u8, cols: u16, rows: u16 },
+ write_file: struct { pane: u8, path: []const u8, bytes: []const u8 },
+ write_dump: []const u8,
+ watch_file: struct { pane: u8, path: []const u8, on: bool },
+ watch_theme: struct { generation: u32, on: bool },
+ dump_themes: struct { pane: u8 },
+ set_clipboard: []const u8,
+ read_clipboard,
+ open_link: []const u8,
+};
+
+/// The one thing a decoder cannot put in a byte buffer: `pipe_resp.outputs` is
+/// a `[]const []const u8`, so the outer array needs somewhere to live. Sized
+/// from the core's own ceiling on selections (`MAX_SELS`), which is what bounds
+/// the count a legitimate `pipe_resp` can carry.
+pub const Scratch = struct {
+ outputs: [pardes.MAX_SELS][]const u8 = undefined,
+};
+
+// ---------------------------------------------------------------------------
+// primitives
+// ---------------------------------------------------------------------------
+
+pub const Writer = struct {
+ buf: []u8,
+ n: usize = 0,
+
+ pub fn init(buf: []u8) Writer {
+ return .{ .buf = buf };
+ }
+
+ pub fn written(w: *const Writer) []const u8 {
+ return w.buf[0..w.n];
+ }
+
+ fn room(w: *Writer, k: usize) Error![]u8 {
+ if (w.buf.len - w.n < k) return error.NoSpace;
+ defer w.n += k;
+ return w.buf[w.n..][0..k];
+ }
+
+ fn putByte(w: *Writer, v: u8) Error!void {
+ (try w.room(1))[0] = v;
+ }
+
+ fn putU16(w: *Writer, v: u16) Error!void {
+ std.mem.writeInt(u16, (try w.room(2))[0..2], v, .little);
+ }
+
+ fn putU32(w: *Writer, v: u32) Error!void {
+ std.mem.writeInt(u32, (try w.room(4))[0..4], v, .little);
+ }
+
+ /// One byte, 0 or 1, never "nonzero". `getBool` refuses anything else.
+ fn putBool(w: *Writer, v: bool) Error!void {
+ try w.putByte(@intFromBool(v));
+ }
+
+ /// IEEE-754 binary32, as its bit pattern in an explicit u32. A scalar
+ /// bitcast and not a struct blit: the format is the one thing a riscv32
+ /// and an x86_64 do agree about.
+ fn putF32(w: *Writer, v: f32) Error!void {
+ try w.putU32(@bitCast(v));
+ }
+
+ fn putBytes(w: *Writer, v: []const u8) Error!void {
+ @memcpy(try w.room(v.len), v);
+ }
+
+ /// Length-prefixed, u16: paths, command lines, a key's text. Nothing here
+ /// is legitimately longer than 64 KiB and a u16 says so on the wire.
+ fn putSlice16(w: *Writer, v: []const u8) Error!void {
+ if (v.len > std.math.maxInt(u16)) return error.Overlong;
+ try w.putU16(@intCast(v.len));
+ try w.putBytes(v);
+ }
+
+ /// ...and u32 for the ones that are: pty output, a paste, a file.
+ fn putSlice32(w: *Writer, v: []const u8) Error!void {
+ if (v.len > max_payload) return error.Overlong;
+ try w.putU32(@intCast(v.len));
+ try w.putBytes(v);
+ }
+};
+
+pub const Reader = struct {
+ bytes: []const u8,
+ i: usize = 0,
+
+ pub fn init(bytes: []const u8) Reader {
+ return .{ .bytes = bytes };
+ }
+
+ /// `i <= bytes.len` is the invariant every getter below preserves, which is
+ /// what makes the subtraction here safe.
+ fn take(r: *Reader, n: usize) Error![]const u8 {
+ if (r.bytes.len - r.i < n) return error.Truncated;
+ defer r.i += n;
+ return r.bytes[r.i..][0..n];
+ }
+
+ fn getByte(r: *Reader) Error!u8 {
+ return (try r.take(1))[0];
+ }
+
+ fn getU16(r: *Reader) Error!u16 {
+ return std.mem.readInt(u16, (try r.take(2))[0..2], .little);
+ }
+
+ fn getU32(r: *Reader) Error!u32 {
+ return std.mem.readInt(u32, (try r.take(4))[0..4], .little);
+ }
+
+ fn getBool(r: *Reader) Error!bool {
+ return switch (try r.getByte()) {
+ 0 => false,
+ 1 => true,
+ else => error.BadValue,
+ };
+ }
+
+ fn getF32(r: *Reader) Error!f32 {
+ const v: f32 = @bitCast(try r.getU32());
+ // A NaN or an infinity here is not a scroll distance. `pinch` in
+ // particular multiplies into a zoom factor the pane keeps, so one bad
+ // value poisons that pane for the rest of the session.
+ if (!std.math.isFinite(v)) return error.BadValue;
+ return v;
+ }
+
+ fn getSlice16(r: *Reader) Error![]const u8 {
+ return r.take(try r.getU16());
+ }
+
+ fn getSlice32(r: *Reader) Error![]const u8 {
+ const n = try r.getU32();
+ if (n > max_payload) return error.Overlong;
+ return r.take(n);
+ }
+
+ /// A pane id the core can actually index. `Event.output{ .pane = 200 }`
+ /// would reach `p.panes[200]`.
+ fn getPane(r: *Reader) Error!u8 {
+ const pane = try r.getByte();
+ if (pane >= pardes.MAX_PANES) return error.BadValue;
+ return pane;
+ }
+
+ /// A grid the core can render into. Zero is refused rather than clamped: a
+ /// frontend that has not been sized yet must not be allowed to collapse a
+ /// shared session to nothing (see server.zig `geometry`).
+ fn getCols(r: *Reader) Error!u16 {
+ const v = try r.getU16();
+ if (v == 0 or v > max_cols) return error.BadValue;
+ return v;
+ }
+
+ fn getRows(r: *Reader) Error!u16 {
+ const v = try r.getU16();
+ if (v == 0 or v > max_rows) return error.BadValue;
+ return v;
+ }
+
+ fn getTag(r: *Reader, comptime T: type) Error!T {
+ return std.enums.fromInt(T, try r.getByte()) orelse error.BadTag;
+ }
+
+ fn end(r: *Reader) Error!void {
+ if (r.i != r.bytes.len) return error.Trailing;
+ }
+};
+
+/// Reserve a message header; `finishMessage` back-patches the length. Two
+/// passes would mean walking a frame's runs twice to find out how long they
+/// are, and the walk is the expensive half.
+fn beginMessage(w: *Writer, tag: u8) Error!usize {
+ const at = w.n;
+ try w.putByte(tag);
+ try w.putU32(0);
+ return at;
+}
+
+fn finishMessage(w: *Writer, at: usize) Error!void {
+ const len = w.n - at - header_len;
+ if (len > max_payload) return error.Overlong;
+ std.mem.writeInt(u32, w.buf[at + 1 ..][0..4], @intCast(len), .little);
+}
+
+/// One complete message at the front of a stream buffer, or null when the rest
+/// has not arrived. `total` is what the caller consumes.
+pub const Framed = struct { tag: u8, payload: []const u8, total: usize };
+
+pub fn framed(buf: []const u8) Error!?Framed {
+ if (buf.len < header_len) return null;
+ const len = std.mem.readInt(u32, buf[1..5], .little);
+ // Refused BEFORE the caller grows a buffer to hold it: an over-long prefix
+ // is the one field in this protocol that can ask for memory.
+ if (len > max_payload) return error.Overlong;
+ if (buf.len - header_len < len) return null;
+ return .{ .tag = buf[0], .payload = buf[header_len..][0..len], .total = header_len + len };
+}
+
+// ---------------------------------------------------------------------------
+// cells
+// ---------------------------------------------------------------------------
+
+fn putColor(w: *Writer, c: pardes.Color) Error!void {
+ switch (c) {
+ .default => try w.putByte(@intFromEnum(ColorTag.default)),
+ .index => |i| {
+ try w.putByte(@intFromEnum(ColorTag.index));
+ try w.putByte(i);
+ },
+ .rgb => |v| {
+ try w.putByte(@intFromEnum(ColorTag.rgb));
+ try w.putBytes(&v);
+ },
+ }
+}
+
+fn getColor(r: *Reader) Error!pardes.Color {
+ return switch (try r.getTag(ColorTag)) {
+ .default => .default,
+ .index => .{ .index = try r.getByte() },
+ .rgb => .{ .rgb = (try r.take(3))[0..3].* },
+ };
+}
+
+const Ul = @FieldType(pardes.CellStyle, "ul");
+
+/// The four enum mappings — underline, font role, mouse button, mouse kind —
+/// and `clientTag`/`serverTag` further down all have one shape: the WIRE
+/// member of the same NAME. So the byte on the socket is still `@intFromEnum`
+/// of an explicitly numbered enum in THIS file and never of a core type —
+/// reordering `CellStyle.ul` changes nothing — and adding a variant to the
+/// core without adding one here does not compile:
+///
+/// error: enum 'wire.UlTag' has no member named 'wavy'
+///
+/// which is the whole property the six hand-written switches this replaced
+/// existed for, in a form that cannot fall out of step. A variant that has to
+/// travel under a DIFFERENT name than the core's gets an arm of its own before
+/// the `inline else`, exactly as `clientTag` keeps `.fs_req`.
+fn putUl(w: *Writer, u: Ul) Error!void {
+ try w.putByte(switch (u) {
+ inline else => |t| @intFromEnum(@field(UlTag, @tagName(t))),
+ });
+}
+
+fn getUl(r: *Reader) Error!Ul {
+ return switch (try r.getTag(UlTag)) {
+ inline else => |t| @field(Ul, @tagName(t)),
+ };
+}
+
+fn putFont(w: *Writer, f: pardes.FontRole) Error!void {
+ try w.putByte(switch (f) {
+ inline else => |t| @intFromEnum(@field(FontTag, @tagName(t))),
+ });
+}
+
+fn getFont(r: *Reader) Error!pardes.FontRole {
+ return switch (try r.getTag(FontTag)) {
+ inline else => |t| @field(pardes.FontRole, @tagName(t)),
+ };
+}
+
+/// How many bytes `putCell` will write. Exact, because `encodeFrame` weighs it
+/// against `run_header` to decide whether to coalesce a run across it.
+fn cellSize(c: *const pardes.Cell) usize {
+ if (c.default) return 1;
+ return 1 + 1 + c.len + colorSize(c.style.fg) + colorSize(c.style.bg) + 1 + 1 + 1;
+}
+
+fn colorSize(c: pardes.Color) usize {
+ return switch (c) {
+ .default => 1,
+ .index => 2,
+ .rgb => 4,
+ };
+}
+
+/// An unpainted cell is ONE byte: `default` means "the shell renders the
+/// terminal's default cell" (pardes.zig `Cell`), so its text and style are not
+/// merely equal to the defaults, they are not part of the frame at all.
+fn putCell(w: *Writer, c: *const pardes.Cell) Error!void {
+ try w.putBool(c.default);
+ if (c.default) return;
+ try w.putByte(c.len);
+ try w.putBytes(c.grapheme());
+ const s = c.style;
+ try putColor(w, s.fg);
+ try putColor(w, s.bg);
+ var attrs: u8 = 0;
+ if (s.bold) attrs |= attr_bold;
+ if (s.dim) attrs |= attr_dim;
+ if (s.italic) attrs |= attr_italic;
+ if (s.blink) attrs |= attr_blink;
+ if (s.reverse) attrs |= attr_reverse;
+ if (s.invisible) attrs |= attr_invisible;
+ if (s.strikethrough) attrs |= attr_strikethrough;
+ try w.putByte(attrs);
+ try putUl(w, s.ul);
+ try putFont(w, s.font_role);
+}
+
+fn decodeCell(r: *Reader) Error!pardes.Cell {
+ if (try r.getBool()) return .{};
+ var c: pardes.Cell = .{ .default = false };
+ const len = try r.getByte();
+ // `Cell.text` is 7 bytes and `grapheme()` slices to `len`. A zero-length
+ // grapheme is a cell with nothing to draw and no way to advance a column.
+ if (len == 0 or len > c.text.len) return error.BadValue;
+ c.len = len;
+ @memcpy(c.text[0..len], try r.take(len));
+ c.style.fg = try getColor(r);
+ c.style.bg = try getColor(r);
+ const attrs = try r.getByte();
+ if (attrs & attr_reserved != 0) return error.BadValue;
+ c.style.bold = attrs & attr_bold != 0;
+ c.style.dim = attrs & attr_dim != 0;
+ c.style.italic = attrs & attr_italic != 0;
+ c.style.blink = attrs & attr_blink != 0;
+ c.style.reverse = attrs & attr_reverse != 0;
+ c.style.invisible = attrs & attr_invisible != 0;
+ c.style.strikethrough = attrs & attr_strikethrough != 0;
+ c.style.ul = try getUl(r);
+ c.style.font_role = try getFont(r);
+ return c;
+}
+
+// ---------------------------------------------------------------------------
+// frames
+// ---------------------------------------------------------------------------
+
+fn putCursor(w: *Writer, cursor: ?Cursor, cols: u16, rows: u16) Error!void {
+ // Fixed six bytes whether or not there is a cursor. An optional field
+ // would save five bytes on a message that is already hundreds, and cost a
+ // branch on both sides of the wire.
+ try w.putBool(cursor != null);
+ const c = cursor orelse Cursor{ .x = 0, .y = 0, .bar = false };
+ // Refused here as well as on decode, so this side cannot build a frame its
+ // own decoder rejects: a dropped frame (sendFrame logs it) leaves a stale
+ // screen, and a refused one takes the frontend's connection with it.
+ if (c.x >= cols or c.y >= rows) return error.BadValue;
+ try w.putU16(c.x);
+ try w.putU16(c.y);
+ try w.putBool(c.bar);
+}
+
+fn getCursor(r: *Reader, cols: u16, rows: u16) Error!?Cursor {
+ const present = try r.getBool();
+ const c: Cursor = .{ .x = try r.getU16(), .y = try r.getU16(), .bar = try r.getBool() };
+ // THE cursor bounds check. Every other field of a frame is checked against
+ // the grid and these two were not, and they are the two a frontend indexes
+ // with directly — `Surface.at(cursor.x, cursor.y)` on a grid this frame
+ // says is 4x2 is an out-of-bounds write in EVERY frontend, not a wrong
+ // glyph. Checked whether or not the cursor is present, because the absent
+ // case is six bytes of padding this encoder writes as zero and a peer that
+ // fills them with anything else is not speaking this protocol.
+ if (c.x >= cols or c.y >= rows) return error.BadValue;
+ return if (present) c else null;
+}
+
+/// Encode one frame of `cells`, as a DIFF against `prev` when `prev` is the
+/// same grid this receiver last acknowledged, and as a FULL frame otherwise.
+///
+/// A late joiner and a resize are the same case and are handled by the same
+/// test: nothing on the far side is comparable to this grid, so `prev` is
+/// empty or a different length and the frame becomes full.
+///
+/// Why a diff at all. Measured against a real `pardes --detach` at 80x24 with
+/// the default theme, which paints every cell and gives most of them an rgb
+/// pair (14 bytes a cell rather than the 8 an uncoloured one costs):
+/// * a full frame is 21965-26699 bytes, depending on what is on screen,
+/// * a frame that changes nothing is 20 — the head, and no runs at all,
+/// * a one-row change (`Msg hi`) is 54 bytes in one run, and a wordier one
+/// 166,
+/// * and an IDLE session sends nothing whatever, because the core is asleep
+/// in `poll(2)` and produces no frame until something happens.
+/// So the diff is worth roughly 500x on the traffic a session actually
+/// generates. The byte-count test below asserts the arithmetic on the board's
+/// own 56x14 grid with uncoloured cells, where it is exact: 6298 against 56.
+/// This transport exists for a frontend on the far end of a slow link, and
+/// shipping the Surface every frame would be 3 MiB/s at the animation tick.
+pub fn encodeFrame(
+ out: []u8,
+ cols: u16,
+ rows: u16,
+ cursor: ?Cursor,
+ cells: []const pardes.Cell,
+ prev: []const pardes.Cell,
+) Error![]const u8 {
+ // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the
+ // assert below so a caller can be told rather than tripped. `max_payload`
+ // is derived from these two, so a larger grid is a frame this decoder
+ // refuses as Overlong — and sendFrame's log line already claims that this
+ // is the error it is catching, which was true of nothing until here.
+ if (cols == 0 or cols > max_cols or rows == 0 or rows > max_rows) return error.Overlong;
+ std.debug.assert(cells.len == @as(usize, cols) * @as(usize, rows));
+ const full = prev.len != cells.len;
+ var w: Writer = .init(out);
+ const at = try beginMessage(&w, @intFromEnum(ServerTag.frame));
+ try w.putByte(@intFromEnum(@as(FrameKind, if (full) .full else .diff)));
+ try w.putU16(cols);
+ try w.putU16(rows);
+ try putCursor(&w, cursor, cols, rows);
+ const nruns_at = w.n;
+ try w.putU32(0);
+
+ var nruns: u32 = 0;
+ var i: usize = 0;
+ while (i < cells.len) {
+ if (!sendCell(cells, prev, full, i)) {
+ i += 1;
+ continue;
+ }
+ const start = i;
+ var run_end = i + 1;
+ i += 1;
+ while (i < cells.len) {
+ if (sendCell(cells, prev, full, i)) {
+ run_end = i + 1;
+ i += 1;
+ continue;
+ }
+ // A gap. Re-sending cells the far side already has is cheaper than
+ // a second run header whenever their encoded size adds up to less
+ // than one — true for short stretches of unpainted cells at a byte
+ // each, and false as soon as one painted cell (eight bytes at its
+ // smallest) is in the way. So the lookahead can never need to pass
+ // `run_header - 1` cells.
+ var gap: usize = 0;
+ var j = i;
+ while (j < cells.len and gap < run_header) : (j += 1) {
+ if (sendCell(cells, prev, full, j)) break;
+ gap += cellSize(&cells[j]);
+ }
+ if (j >= cells.len or gap >= run_header) break;
+ i = j;
+ }
+ try w.putU32(@intCast(start));
+ try w.putU16(@intCast(run_end - start));
+ for (cells[start..run_end]) |*c| try putCell(&w, c);
+ nruns += 1;
+ i = run_end;
+ }
+ std.mem.writeInt(u32, w.buf[nruns_at..][0..4], nruns, .little);
+ try finishMessage(&w, at);
+ return w.written();
+}
+
+fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i: usize) bool {
+ // Full: the receiver reset the grid, so unpainted cells are already right.
+ // Diff: anything the receiver cannot already be showing.
+ if (full) return !cells[i].default;
+ return !cells[i].visuallyEqual(&prev[i]);
+}
+
+// ---------------------------------------------------------------------------
+// frontend -> core
+// ---------------------------------------------------------------------------
+
+/// Which tag a message travels under: the `ClientTag` of the same NAME, so a
+/// new `Event` variant does not compile until it has a number here. See
+/// `putUl` for the error it produces and why this is not a written-out table.
+fn clientTag(msg: ClientMsg) ClientTag {
+ return switch (msg) {
+ .event => |ev| switch (ev) {
+ // Not on the wire, and not an omission: see the module header.
+ // The acme mount lives with the core, so this event is raised in
+ // the same process that answers it and never crosses a socket.
+ .fs_req => unreachable,
+ inline else => |_, t| @field(ClientTag, @tagName(t)),
+ },
+ inline else => |_, t| @field(ClientTag, @tagName(t)),
+ };
+}
+
+/// Does the build this frontend was compiled with carry pixel dimensions in a
+/// resize? The FIELD is comptime-conditional (pardes.zig `CellPixels`); the
+/// WIRE is not.
+const has_cell_pixels = @hasField(pardes.CellPixels, "w");
+
+pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
+ var w: Writer = .init(out);
+ const at = try beginMessage(&w, @intFromEnum(clientTag(msg)));
+ switch (msg) {
+ .hello => |h| {
+ try w.putU16(h.version);
+ try w.putU16(h.cols);
+ try w.putU16(h.rows);
+ },
+ .bye => {},
+ .event => |ev| switch (ev) {
+ .key => |k| {
+ try w.putU32(k.cp);
+ try w.putSlice16(k.text);
+ try w.putBool(k.ctrl);
+ try w.putBool(k.alt);
+ try w.putBool(k.shift);
+ },
+ .mouse => |m| {
+ try w.putByte(switch (m.button) {
+ inline else => |t| @intFromEnum(@field(ButtonTag, @tagName(t))),
+ });
+ try w.putByte(switch (m.kind) {
+ inline else => |t| @intFromEnum(@field(KindTag, @tagName(t))),
+ });
+ try w.putU16(m.col);
+ try w.putU16(m.row);
+ try w.putBool(m.ctrl);
+ },
+ .resize => |rs| {
+ try w.putU16(rs.cols);
+ try w.putU16(rs.rows);
+ // The conventional 1:2 cell aspect when this build has no
+ // pixels of its own, which is the same default the field
+ // carries where it exists.
+ try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.w else 8);
+ try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.h else 16);
+ },
+ .output => |o| {
+ try w.putByte(o.pane);
+ try w.putSlice32(o.bytes);
+ },
+ .eof => |e| try w.putByte(e.pane),
+ .lsp_resp => |l| {
+ try w.putU32(l.id);
+ try w.putSlice32(l.rows);
+ },
+ .pipe_resp => |p| {
+ try w.putU32(p.id);
+ try w.putBool(p.success);
+ if (p.outputs.len > pardes.MAX_SELS) return error.Overlong;
+ try w.putU16(@intCast(p.outputs.len));
+ for (p.outputs) |o| try w.putSlice32(o);
+ },
+ .file_changed => |f| {
+ try w.putByte(f.pane);
+ try w.putSlice32(f.bytes);
+ },
+ .paste => |b| try w.putSlice32(b),
+ .command => |line| try w.putSlice16(line),
+ .pdf_scroll => |s| {
+ try w.putByte(s.pane);
+ try w.putF32(s.delta_pixels);
+ },
+ .pinch => |v| try w.putF32(v),
+ .touch_scroll => |v| try w.putF32(v),
+ .pointer_leave, .tick => {},
+ .fs_req => unreachable,
+ },
+ }
+ try finishMessage(&w, at);
+ return w.written();
+}
+
+pub fn decodeClient(tag: u8, payload: []const u8, scratch: *Scratch) Error!ClientMsg {
+ var r: Reader = .init(payload);
+ const msg: ClientMsg = switch (std.enums.fromInt(ClientTag, tag) orelse return error.BadTag) {
+ .hello => .{ .hello = .{
+ .version = try r.getU16(),
+ .cols = try r.getCols(),
+ .rows = try r.getRows(),
+ } },
+ .bye => .bye,
+ .key => blk: {
+ const cp = try r.getU32();
+ // `Key.cp` is a u21, and the specials live in the private-use
+ // plane below this bound. A larger number is not a codepoint and
+ // @intCast of it would panic in a release build's own decoder.
+ if (cp > 0x10FFFF) return error.BadValue;
+ break :blk .{ .event = .{ .key = .{
+ .cp = @intCast(cp),
+ .text = try r.getSlice16(),
+ .ctrl = try r.getBool(),
+ .alt = try r.getBool(),
+ .shift = try r.getBool(),
+ } } };
+ },
+ .mouse => .{ .event = .{ .mouse = .{
+ .button = switch (try r.getTag(ButtonTag)) {
+ inline else => |t| @field(pardes.Mouse.Button, @tagName(t)),
+ },
+ .kind = switch (try r.getTag(KindTag)) {
+ inline else => |t| @field(pardes.Mouse.Kind, @tagName(t)),
+ },
+ .col = try r.getU16(),
+ .row = try r.getU16(),
+ .ctrl = try r.getBool(),
+ } } },
+ .resize => blk: {
+ var ev: pardes.Event = .{ .resize = .{ .cols = try r.getCols(), .rows = try r.getRows() } };
+ const px_w = try r.getU16();
+ const px_h = try r.getU16();
+ // Read either way — the bytes are on the wire — and kept only by a
+ // build that has somewhere to keep them.
+ if (comptime has_cell_pixels) ev.resize.cell_pixels = .{ .w = px_w, .h = px_h };
+ break :blk .{ .event = ev };
+ },
+ .output => .{ .event = .{ .output = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } },
+ .eof => .{ .event = .{ .eof = .{ .pane = try r.getPane() } } },
+ .lsp_resp => .{ .event = .{ .lsp_resp = .{ .id = try r.getU32(), .rows = try r.getSlice32() } } },
+ .pipe_resp => blk: {
+ const id = try r.getU32();
+ const success = try r.getBool();
+ const n = try r.getU16();
+ if (n > scratch.outputs.len) return error.Overlong;
+ for (scratch.outputs[0..n]) |*o| o.* = try r.getSlice32();
+ break :blk .{ .event = .{ .pipe_resp = .{
+ .id = id,
+ .success = success,
+ .outputs = scratch.outputs[0..n],
+ } } };
+ },
+ .file_changed => .{ .event = .{ .file_changed = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } },
+ .paste => .{ .event = .{ .paste = try r.getSlice32() } },
+ .command => .{ .event = .{ .command = try r.getSlice16() } },
+ .pdf_scroll => .{ .event = .{ .pdf_scroll = .{ .pane = try r.getPane(), .delta_pixels = try r.getF32() } } },
+ .pinch => .{ .event = .{ .pinch = try r.getF32() } },
+ .touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } },
+ .pointer_leave => .{ .event = .pointer_leave },
+ .tick => .{ .event = .tick },
+ };
+ try r.end();
+ return msg;
+}
+
+// ---------------------------------------------------------------------------
+// core -> frontend
+// ---------------------------------------------------------------------------
+
+/// ...and the same rule in the same shape: the `ServerTag` of the same name.
+fn serverTag(msg: ServerMsg) ServerTag {
+ return switch (msg) {
+ inline else => |_, t| @field(ServerTag, @tagName(t)),
+ };
+}
+
+/// Every server message EXCEPT a frame, which has its own encoder because its
+/// payload is a walk of two grids rather than a value (see `encodeFrame`).
+pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 {
+ var w: Writer = .init(out);
+ const at = try beginMessage(&w, @intFromEnum(serverTag(msg)));
+ switch (msg) {
+ .welcome => |v| {
+ try w.putU16(v.version);
+ try w.putByte(v.slot);
+ try w.putU16(v.cols);
+ try w.putU16(v.rows);
+ },
+ .refuse => |why| try w.putByte(@intFromEnum(why)),
+ // A frame's runs are not a value this union can hold; the server calls
+ // encodeFrame directly and this arm exists so the switch stays
+ // exhaustive over ServerMsg.
+ .frame => return error.BadValue,
+ .quit => {},
+ .spawn => |s| {
+ try w.putByte(s.pane);
+ try w.putSlice16(s.cwd);
+ },
+ .pty_write => |p| {
+ try w.putByte(p.pane);
+ try w.putSlice32(p.bytes);
+ },
+ .pty_resize => |p| {
+ try w.putByte(p.pane);
+ try w.putU16(p.cols);
+ try w.putU16(p.rows);
+ },
+ .write_file => |f| {
+ try w.putByte(f.pane);
+ try w.putSlice16(f.path);
+ try w.putSlice32(f.bytes);
+ },
+ .write_dump => |b| try w.putSlice32(b),
+ .watch_file => |v| {
+ try w.putByte(v.pane);
+ try w.putSlice16(v.path);
+ try w.putBool(v.on);
+ },
+ .watch_theme => |t| {
+ try w.putU32(t.generation);
+ try w.putBool(t.on);
+ },
+ .dump_themes => |d| try w.putByte(d.pane),
+ .set_clipboard => |t| try w.putSlice32(t),
+ .read_clipboard => {},
+ .open_link => |u| try w.putSlice16(u),
+ }
+ try finishMessage(&w, at);
+ return w.written();
+}
+
+/// Slack over a message's variable payload, covering every fixed field any
+/// message here has plus its own header. One loose constant rather than a
+/// field-by-field count: a bound that is 32 bytes generous costs one `memcpy`
+/// worth of nothing, and a bound that is one byte tight is a bug that only
+/// shows up on the message nobody tested.
+const msg_slack = header_len + 32;
+
+/// An upper bound on `encodeServer`'s output, so a caller sizes its buffer
+/// once instead of guessing and retrying.
+pub fn serverBound(msg: ServerMsg) usize {
+ return msg_slack + switch (msg) {
+ .welcome, .refuse, .quit, .pty_resize, .watch_theme, .dump_themes, .read_clipboard => 0,
+ // A frame is bounded by its grid, not by this: see `frameBound`.
+ .frame => |f| frameBound(f.cols, f.rows),
+ .spawn => |s| s.cwd.len,
+ .pty_write => |p| p.bytes.len,
+ .write_file => |f| f.path.len + f.bytes.len,
+ .write_dump => |b| b.len,
+ .watch_file => |v| v.path.len,
+ .set_clipboard => |t| t.len,
+ .open_link => |u| u.len,
+ };
+}
+
+/// ...and the same for the frontend's side of the wire.
+pub fn clientBound(msg: ClientMsg) usize {
+ return msg_slack + switch (msg) {
+ .hello, .bye => 0,
+ .event => |ev| switch (ev) {
+ .mouse, .resize, .eof, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .tick => 0,
+ .key => |k| k.text.len,
+ .output => |o| o.bytes.len,
+ .lsp_resp => |l| l.rows.len,
+ .pipe_resp => |p| blk: {
+ // Each output carries its own u32 prefix, so the count is part
+ // of the bound and not just the bytes.
+ var total: usize = p.outputs.len * 4;
+ for (p.outputs) |o| total += o.len;
+ break :blk total;
+ },
+ .file_changed => |f| f.bytes.len,
+ .paste => |b| b.len,
+ .command => |line| line.len,
+ .fs_req => unreachable,
+ },
+ };
+}
+
+pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg {
+ var r: Reader = .init(payload);
+ const msg: ServerMsg = switch (std.enums.fromInt(ServerTag, tag) orelse return error.BadTag) {
+ .welcome => .{ .welcome = .{
+ .version = try r.getU16(),
+ .slot = try r.getByte(),
+ .cols = try r.getCols(),
+ .rows = try r.getRows(),
+ } },
+ .refuse => .{ .refuse = try r.getTag(Refusal) },
+ .frame => blk: {
+ const kind = try r.getTag(FrameKind);
+ const cols = try r.getCols();
+ const rows = try r.getRows();
+ const cursor = try getCursor(&r, cols, rows);
+ const nruns = try r.getU32();
+ // One run per cell is the worst an encoder emits, so a bigger
+ // count cannot be describing this grid. Checked HERE rather than
+ // in `apply`'s loop so the number is refused before it is used to
+ // bound anything.
+ if (nruns > @as(u32, cols) * @as(u32, rows)) return error.Overlong;
+ const runs = r.bytes[r.i..];
+ r.i = r.bytes.len;
+ break :blk .{ .frame = .{
+ .kind = kind,
+ .cols = cols,
+ .rows = rows,
+ .cursor = cursor,
+ .nruns = nruns,
+ .runs = runs,
+ } };
+ },
+ .quit => .quit,
+ .spawn => .{ .spawn = .{ .pane = try r.getPane(), .cwd = try r.getSlice16() } },
+ .pty_write => .{ .pty_write = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } },
+ .pty_resize => .{ .pty_resize = .{
+ .pane = try r.getPane(),
+ .cols = try r.getU16(),
+ .rows = try r.getU16(),
+ } },
+ .write_file => .{ .write_file = .{
+ .pane = try r.getPane(),
+ .path = try r.getSlice16(),
+ .bytes = try r.getSlice32(),
+ } },
+ .write_dump => .{ .write_dump = try r.getSlice32() },
+ .watch_file => .{ .watch_file = .{
+ .pane = try r.getPane(),
+ .path = try r.getSlice16(),
+ .on = try r.getBool(),
+ } },
+ .watch_theme => .{ .watch_theme = .{ .generation = try r.getU32(), .on = try r.getBool() } },
+ .dump_themes => .{ .dump_themes = .{ .pane = try r.getPane() } },
+ .set_clipboard => .{ .set_clipboard = try r.getSlice32() },
+ .read_clipboard => .read_clipboard,
+ .open_link => .{ .open_link = try r.getSlice16() },
+ };
+ try r.end();
+ return msg;
+}
+
+// ---------------------------------------------------------------------------
+// tests
+// ---------------------------------------------------------------------------
+//
+// Two obligations. Every message round-trips to a deep-equal value, because a
+// codec written by hand is a codec whose two halves drift; and every malformed
+// shape is REFUSED, because this parser is fed by a socket and a frame that is
+// misread rather than refused is an out-of-bounds index.
+
+const testing = std.testing;
+
+/// Round-trip one frontend -> core message through the framing too, so a
+/// length prefix that disagrees with the payload cannot pass.
+fn roundClient(buf: []u8, msg: ClientMsg, scratch: *Scratch) !ClientMsg {
+ const bytes = try encodeClient(buf, msg);
+ const f = (try framed(bytes)).?;
+ try testing.expectEqual(bytes.len, f.total);
+ return decodeClient(f.tag, f.payload, scratch);
+}
+
+fn roundServer(buf: []u8, msg: ServerMsg) !ServerMsg {
+ const bytes = try encodeServer(buf, msg);
+ const f = (try framed(bytes)).?;
+ try testing.expectEqual(bytes.len, f.total);
+ return decodeServer(f.tag, f.payload);
+}
+
+test "detached wire: every Event variant round-trips" {
+ var buf: [4096]u8 = undefined;
+ var scratch: Scratch = .{};
+
+ // The tag space is the protocol's own, so assert the numbers themselves:
+ // a renumbering here breaks every deployed frontend and must be a diff
+ // somebody reads, not a silent change.
+ try testing.expectEqual(@as(u8, 0x01), @intFromEnum(ClientTag.hello));
+ try testing.expectEqual(@as(u8, 0x10), @intFromEnum(ClientTag.key));
+ try testing.expectEqual(@as(u8, 0x1e), @intFromEnum(ClientTag.tick));
+
+ {
+ const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }, &scratch);
+ try testing.expectEqual(version, got.hello.version);
+ try testing.expectEqual(@as(u16, 80), got.hello.cols);
+ try testing.expectEqual(@as(u16, 24), got.hello.rows);
+ }
+ try testing.expectEqual(ClientMsg.bye, try roundClient(&buf, .bye, &scratch));
+
+ {
+ const key: pardes.Key = .{ .cp = pardes.Key.page_down, .text = "ü", .ctrl = true, .alt = false, .shift = true };
+ const got = (try roundClient(&buf, .{ .event = .{ .key = key } }, &scratch)).event.key;
+ try testing.expectEqual(key.cp, got.cp);
+ try testing.expectEqualStrings(key.text, got.text);
+ try testing.expectEqual(key.ctrl, got.ctrl);
+ try testing.expectEqual(key.alt, got.alt);
+ try testing.expectEqual(key.shift, got.shift);
+ }
+ {
+ // Every button and every kind, because the two mapping switches are
+ // the only place a value can be mistranslated and still decode.
+ for (std.enums.values(pardes.Mouse.Button)) |button| {
+ for (std.enums.values(pardes.Mouse.Kind)) |kind| {
+ const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true };
+ const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } }, &scratch)).event.mouse;
+ try testing.expectEqual(m.button, got.button);
+ try testing.expectEqual(m.kind, got.kind);
+ try testing.expectEqual(m.col, got.col);
+ try testing.expectEqual(m.row, got.row);
+ try testing.expectEqual(m.ctrl, got.ctrl);
+ }
+ }
+ }
+ {
+ const got = (try roundClient(&buf, .{ .event = .{ .resize = .{ .cols = 56, .rows = 14 } } }, &scratch)).event.resize;
+ try testing.expectEqual(@as(u16, 56), got.cols);
+ try testing.expectEqual(@as(u16, 14), got.rows);
+ // Pixels travel whether or not this build has them; where it does, the
+ // conventional 1:2 default survives the trip.
+ if (comptime has_cell_pixels) {
+ try testing.expectEqual(@as(u16, 8), got.cell_pixels.w);
+ try testing.expectEqual(@as(u16, 16), got.cell_pixels.h);
+ }
+ }
+ {
+ const got = (try roundClient(&buf, .{ .event = .{ .output = .{ .pane = 3, .bytes = "hi\x00there" } } }, &scratch)).event.output;
+ try testing.expectEqual(@as(u8, 3), got.pane);
+ try testing.expectEqualStrings("hi\x00there", got.bytes);
+ }
+ try testing.expectEqual(@as(u8, 15), (try roundClient(&buf, .{ .event = .{ .eof = .{ .pane = 15 } } }, &scratch)).event.eof.pane);
+ {
+ const got = (try roundClient(&buf, .{ .event = .{ .lsp_resp = .{ .id = 0xdeadbeef, .rows = "a:1:2-3 x" } } }, &scratch)).event.lsp_resp;
+ try testing.expectEqual(@as(u32, 0xdeadbeef), got.id);
+ try testing.expectEqualStrings("a:1:2-3 x", got.rows);
+ }
+ {
+ // Including an EMPTY output, which is what a filter that consumed a
+ // selection and printed nothing returns.
+ const outputs: []const []const u8 = &.{ "AA\n", "", "cc" };
+ const got = (try roundClient(&buf, .{ .event = .{ .pipe_resp = .{ .id = 9, .success = true, .outputs = outputs } } }, &scratch)).event.pipe_resp;
+ try testing.expectEqual(@as(u32, 9), got.id);
+ try testing.expect(got.success);
+ try testing.expectEqual(@as(usize, 3), got.outputs.len);
+ for (outputs, got.outputs) |want, have| try testing.expectEqualStrings(want, have);
+ }
+ {
+ const got = (try roundClient(&buf, .{ .event = .{ .file_changed = .{ .pane = 0, .bytes = "" } } }, &scratch)).event.file_changed;
+ try testing.expectEqual(@as(u8, 0), got.pane);
+ try testing.expectEqualStrings("", got.bytes);
+ }
+ try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } }, &scratch)).event.paste);
+ try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } }, &scratch)).event.command);
+ {
+ const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } }, &scratch)).event.pdf_scroll;
+ try testing.expectEqual(@as(u8, 2), got.pane);
+ try testing.expectEqual(@as(f32, -12.5), got.delta_pixels);
+ }
+ try testing.expectEqual(@as(f32, 1.25), (try roundClient(&buf, .{ .event = .{ .pinch = 1.25 } }, &scratch)).event.pinch);
+ try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } }, &scratch)).event.touch_scroll);
+ try testing.expectEqual(
+ std.meta.Tag(pardes.Event).pointer_leave,
+ (try roundClient(&buf, .{ .event = .pointer_leave }, &scratch)).event,
+ );
+ try testing.expectEqual(
+ std.meta.Tag(pardes.Event).tick,
+ (try roundClient(&buf, .{ .event = .tick }, &scratch)).event,
+ );
+}
+
+test "detached wire: every server message round-trips" {
+ var buf: [4096]u8 = undefined;
+
+ {
+ const got = (try roundServer(&buf, .{ .welcome = .{ .slot = 2, .cols = 56, .rows = 14 } })).welcome;
+ try testing.expectEqual(version, got.version);
+ try testing.expectEqual(@as(u8, 2), got.slot);
+ try testing.expectEqual(@as(u16, 56), got.cols);
+ try testing.expectEqual(@as(u16, 14), got.rows);
+ }
+ for (std.enums.values(Refusal)) |why|
+ try testing.expectEqual(why, (try roundServer(&buf, .{ .refuse = why })).refuse);
+ try testing.expectEqual(ServerMsg.quit, try roundServer(&buf, .quit));
+ {
+ const got = (try roundServer(&buf, .{ .spawn = .{ .pane = 1, .cwd = "/home/x" } })).spawn;
+ try testing.expectEqual(@as(u8, 1), got.pane);
+ try testing.expectEqualStrings("/home/x", got.cwd);
+ }
+ {
+ const got = (try roundServer(&buf, .{ .pty_write = .{ .pane = 1, .bytes = "ls\r" } })).pty_write;
+ try testing.expectEqual(@as(u8, 1), got.pane);
+ try testing.expectEqualStrings("ls\r", got.bytes);
+ }
+ {
+ const got = (try roundServer(&buf, .{ .pty_resize = .{ .pane = 1, .cols = 80, .rows = 24 } })).pty_resize;
+ try testing.expectEqual(@as(u16, 80), got.cols);
+ try testing.expectEqual(@as(u16, 24), got.rows);
+ }
+ {
+ const got = (try roundServer(&buf, .{ .write_file = .{ .pane = 4, .path = "/tmp/a", .bytes = "body\n" } })).write_file;
+ try testing.expectEqual(@as(u8, 4), got.pane);
+ try testing.expectEqualStrings("/tmp/a", got.path);
+ try testing.expectEqualStrings("body\n", got.bytes);
+ }
+ try testing.expectEqualStrings(".{}", (try roundServer(&buf, .{ .write_dump = ".{}" })).write_dump);
+ {
+ const got = (try roundServer(&buf, .{ .watch_file = .{ .pane = 0, .path = "/tmp/b", .on = true } })).watch_file;
+ try testing.expectEqualStrings("/tmp/b", got.path);
+ try testing.expect(got.on);
+ }
+ {
+ const got = (try roundServer(&buf, .{ .watch_theme = .{ .generation = 7, .on = false } })).watch_theme;
+ try testing.expectEqual(@as(u32, 7), got.generation);
+ try testing.expect(!got.on);
+ }
+ try testing.expectEqual(@as(u8, 5), (try roundServer(&buf, .{ .dump_themes = .{ .pane = 5 } })).dump_themes.pane);
+ try testing.expectEqualStrings("yank", (try roundServer(&buf, .{ .set_clipboard = "yank" })).set_clipboard);
+ try testing.expectEqual(ServerMsg.read_clipboard, try roundServer(&buf, .read_clipboard));
+ try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link);
+}
+
+/// A grid with something in every corner: a default cell, a plain ASCII cell,
+/// an indexed pair, an rgb pair with every attribute on, and a multi-byte
+/// grapheme — the five shapes `putCell` branches on.
+fn sampleGrid(cells: []pardes.Cell) void {
+ @memset(cells, .{});
+ cells[0] = .{ .text = "x".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ cells[1] = .{
+ .text = "y".* ++ @as([6]u8, @splat(0)),
+ .len = 1,
+ .default = false,
+ .style = .{ .fg = .{ .index = 3 }, .bg = .{ .index = 250 }, .bold = true, .ul = .curly },
+ };
+ cells[2] = .{
+ .text = "→".* ++ @as([4]u8, @splat(0)),
+ .len = 3,
+ .default = false,
+ .style = .{
+ .fg = .{ .rgb = .{ 1, 2, 3 } },
+ .bg = .{ .rgb = .{ 250, 251, 252 } },
+ .bold = true,
+ .dim = true,
+ .italic = true,
+ .blink = true,
+ .reverse = true,
+ .invisible = true,
+ .strikethrough = true,
+ .ul = .dashed,
+ .font_role = .tagline,
+ },
+ };
+ cells[cells.len - 1] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+}
+
+fn expectGridEqual(want: []const pardes.Cell, have: []const pardes.Cell) !void {
+ try testing.expectEqual(want.len, have.len);
+ // `visuallyEqual` and not `std.meta.eql`: `Cell.text` past `len` is
+ // scratch left by an earlier grapheme, the decoder does not invent it, and
+ // pardes.zig says in as many words that it must never manufacture a diff.
+ for (want, have, 0..) |*a, *b, i| if (!a.visuallyEqual(b)) {
+ std.debug.print("cell {d} differs: {any} vs {any}\n", .{ i, a.*, b.* });
+ return error.CellMismatch;
+ };
+}
+
+test "detached wire: a full frame carries the grid, a diff carries the change" {
+ const cols: u16 = 56;
+ const rows: u16 = 14;
+ const n = @as(usize, cols) * rows;
+ const gpa = testing.allocator;
+
+ const cells = try gpa.alloc(pardes.Cell, n);
+ defer gpa.free(cells);
+ const mirror = try gpa.alloc(pardes.Cell, n);
+ defer gpa.free(mirror);
+ const buf = try gpa.alloc(u8, frameBound(cols, rows));
+ defer gpa.free(buf);
+
+ sampleGrid(cells);
+ @memset(mirror, .{});
+
+ // FULL: nothing on the far side is comparable, which is the late joiner
+ // and the resize both.
+ const full_bytes = try encodeFrame(buf, cols, rows, .{ .x = 3, .y = 4, .bar = true }, cells, &.{});
+ {
+ const f = (try framed(full_bytes)).?;
+ const msg = (try decodeServer(f.tag, f.payload)).frame;
+ try testing.expectEqual(FrameKind.full, msg.kind);
+ try testing.expectEqual(cols, msg.cols);
+ try testing.expectEqual(rows, msg.rows);
+ try testing.expectEqual(@as(u16, 3), msg.cursor.?.x);
+ try testing.expectEqual(@as(u16, 4), msg.cursor.?.y);
+ try testing.expect(msg.cursor.?.bar);
+ try msg.apply(mirror);
+ try expectGridEqual(cells, mirror);
+ }
+
+ // DIFF: two cells move. The mirror is already in sync, so this is what a
+ // steady-state frame looks like.
+ const before = try gpa.dupe(pardes.Cell, cells);
+ defer gpa.free(before);
+ cells[100] = .{ .text = "q".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ cells[0] = .{}; // ...and one goes back to unpainted, which a diff must say
+ const diff_bytes = try encodeFrame(buf, cols, rows, null, cells, before);
+ {
+ const f = (try framed(diff_bytes)).?;
+ const msg = (try decodeServer(f.tag, f.payload)).frame;
+ try testing.expectEqual(FrameKind.diff, msg.kind);
+ try testing.expectEqual(@as(?Cursor, null), msg.cursor);
+ try testing.expectEqual(@as(u32, 2), msg.nruns);
+ try msg.apply(mirror);
+ try expectGridEqual(cells, mirror);
+ }
+
+ // An unchanged frame is the head and nothing else: no runs, and the
+ // receiver keeps what it has. This is what makes an idle session silent.
+ {
+ const idle = try encodeFrame(buf, cols, rows, null, cells, cells);
+ try testing.expectEqual(@as(usize, header_len + frame_head), idle.len);
+ const f = (try framed(idle)).?;
+ const msg = (try decodeServer(f.tag, f.payload)).frame;
+ try testing.expectEqual(@as(u32, 0), msg.nruns);
+ try msg.apply(mirror);
+ try expectGridEqual(cells, mirror);
+ }
+}
+
+test "detached wire: the diff is worth having, in bytes, on the board's grid" {
+ // The numbers quoted in `encodeFrame`'s comment, asserted so the claim
+ // cannot rot. 56x14 is the ESP32-P4 board's default grid (esp32p4.zig).
+ const cols: u16 = 56;
+ const rows: u16 = 14;
+ const n = @as(usize, cols) * rows;
+ const gpa = testing.allocator;
+
+ const cells = try gpa.alloc(pardes.Cell, n);
+ defer gpa.free(cells);
+ const buf = try gpa.alloc(u8, frameBound(cols, rows));
+ defer gpa.free(buf);
+
+ // Every cell painted with a plain ASCII glyph and default colors, which is
+ // what a pardes frame overwhelmingly is: eight bytes a cell.
+ for (cells) |*c| c.* = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ const full = (try encodeFrame(buf, cols, rows, null, cells, &.{})).len;
+ try testing.expectEqual(@as(usize, header_len + frame_head + run_header + n * 8), full);
+ try testing.expectEqual(@as(usize, 6298), full);
+
+ const before = try gpa.dupe(pardes.Cell, cells);
+ defer gpa.free(before);
+ // A keystroke: one glyph replaced, and the cursor's old and new cells
+ // repainted. Three cells, adjacent enough to coalesce into two runs.
+ cells[300] = .{ .text = "b".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ cells[301] = .{ .text = "c".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ cells[500] = .{ .text = "d".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ const diff = (try encodeFrame(buf, cols, rows, null, cells, before)).len;
+ try testing.expectEqual(@as(usize, header_len + frame_head + 2 * run_header + 3 * 8), diff);
+ try testing.expectEqual(@as(usize, 56), diff);
+ // The whole reason this codec exists rather than shipping the Surface.
+ try testing.expect(full / diff > 100);
+}
+
+test "detached wire: a run is coalesced across a gap only when that is cheaper" {
+ const cols: u16 = 8;
+ const rows: u16 = 1;
+ var cells: [8]pardes.Cell = @splat(.{});
+ var prev: [8]pardes.Cell = @splat(.{});
+ var buf: [512]u8 = undefined;
+
+ // Two changes with three UNPAINTED cells between them. Re-sending those is
+ // three bytes; a second run header is six. One run.
+ cells[0] = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ cells[4] = .{ .text = "b".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ {
+ const f = (try framed(try encodeFrame(&buf, cols, rows, null, &cells, &prev))).?;
+ try testing.expectEqual(@as(u32, 1), (try decodeServer(f.tag, f.payload)).frame.nruns);
+ }
+ // Now put a PAINTED cell in the gap that both sides already agree about.
+ // Eight bytes to re-send against six for a header: two runs.
+ const painted: pardes.Cell = .{ .text = "-".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ cells[2] = painted;
+ prev[2] = painted;
+ {
+ const f = (try framed(try encodeFrame(&buf, cols, rows, null, &cells, &prev))).?;
+ try testing.expectEqual(@as(u32, 2), (try decodeServer(f.tag, f.payload)).frame.nruns);
+ }
+ // Whichever it chose, the receiver ends up with the same grid.
+ var mirror: [8]pardes.Cell = prev;
+ const f = (try framed(try encodeFrame(&buf, cols, rows, null, &cells, &prev))).?;
+ try (try decodeServer(f.tag, f.payload)).frame.apply(&mirror);
+ try expectGridEqual(&cells, &mirror);
+}
+
+test "detached wire: a truncated frame is refused at every length" {
+ var buf: [4096]u8 = undefined;
+ var scratch: Scratch = .{};
+
+ // Every prefix of a real message. The framing must say "not yet" for the
+ // ones that are short, and the decoder must say "truncated" for a payload
+ // whose header lies about its length — never read past the slice.
+ const whole = try encodeClient(&buf, .{ .event = .{ .key = .{ .cp = 'a', .text = "a" } } });
+ var copy: [64]u8 = undefined;
+ @memcpy(copy[0..whole.len], whole);
+ for (header_len + 1..whole.len) |cut| {
+ try testing.expectEqual(@as(?Framed, null), try framed(copy[0..cut]));
+ // ...and the same bytes handed to the decoder with the header's length
+ // left claiming the whole message, which is how a decoder is walked
+ // off the end of its buffer.
+ try testing.expectError(error.Truncated, decodeClient(copy[0], copy[header_len..cut], &scratch));
+ }
+ // Shorter than the header itself is not yet a message at all.
+ for (0..header_len + 1) |cut|
+ try testing.expectEqual(@as(?Framed, null), try framed(copy[0..cut]));
+ // A payload with bytes LEFT OVER is refused too: it is not this message.
+ try testing.expectError(error.Trailing, decodeClient(@intFromEnum(ClientTag.tick), "x", &scratch));
+ try testing.expectError(error.Trailing, decodeServer(@intFromEnum(ServerTag.quit), "x"));
+}
+
+test "detached wire: an unknown tag is refused, never guessed" {
+ var scratch: Scratch = .{};
+ // 0x00 and 0xff have never been assigned, and 0x0f sits in the gap between
+ // the session tags and the input tags. All three are the same answer.
+ for ([_]u8{ 0x00, 0x0f, 0x1f, 0xff }) |tag| {
+ try testing.expectError(error.BadTag, decodeClient(tag, "", &scratch));
+ try testing.expectError(error.BadTag, decodeServer(tag, ""));
+ }
+ // A tag NESTED in a payload gets the same treatment: a color, an
+ // underline style, a mouse button, a refusal reason.
+ try testing.expectError(error.BadTag, decodeServer(@intFromEnum(ServerTag.refuse), &.{0x7f}));
+ try testing.expectError(error.BadTag, decodeClient(
+ @intFromEnum(ClientTag.mouse),
+ &.{ 0x09, 0x00, 0, 0, 0, 0, 0 },
+ &scratch,
+ ));
+}
+
+test "detached wire: an over-long length prefix is refused before it is believed" {
+ // The prefix a hostile or corrupt peer sends to make the receiver allocate
+ // or index by it. `framed` must refuse rather than wait for 4 GiB.
+ var head: [header_len]u8 = .{ @intFromEnum(ClientTag.paste), 0, 0, 0, 0 };
+ std.mem.writeInt(u32, head[1..5], max_payload + 1, .little);
+ try testing.expectError(error.Overlong, framed(&head));
+ std.mem.writeInt(u32, head[1..5], std.math.maxInt(u32), .little);
+ try testing.expectError(error.Overlong, framed(&head));
+ // At the boundary it is a legal prefix and simply has not all arrived.
+ std.mem.writeInt(u32, head[1..5], max_payload, .little);
+ try testing.expectEqual(@as(?Framed, null), try framed(&head));
+
+ // An INNER length prefix, past the payload it sits in but inside the
+ // protocol's cap — the one an outer-frame check cannot catch.
+ var scratch: Scratch = .{};
+ var paste: [8]u8 = undefined;
+ std.mem.writeInt(u32, paste[0..4], 4096, .little);
+ @memcpy(paste[4..8], "abcd");
+ try testing.expectError(error.Truncated, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch));
+ // ...and past the cap, which is refused rather than read.
+ std.mem.writeInt(u32, paste[0..4], max_payload + 1, .little);
+ try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch));
+}
+
+test "detached wire: a frame that lies about its runs cannot walk out of the grid" {
+ const cols: u16 = 4;
+ const rows: u16 = 2;
+ var grid: [8]pardes.Cell = @splat(.{});
+
+ // start = 6, count = 4 on an 8-cell grid: the check that matters, and the
+ // one an `start + count > len` spelling would miss on overflow.
+ var runs: [10]u8 = undefined;
+ std.mem.writeInt(u32, runs[0..4], 6, .little);
+ std.mem.writeInt(u16, runs[4..6], 4, .little);
+ runs[6] = 1;
+ const f: Frame = .{ .kind = .diff, .cols = cols, .rows = rows, .cursor = null, .nruns = 1, .runs = runs[0..7] };
+ try testing.expectError(error.Overlong, f.apply(&grid));
+
+ // A start past the end entirely, and the 32-bit wrap.
+ std.mem.writeInt(u32, runs[0..4], std.math.maxInt(u32) - 1, .little);
+ std.mem.writeInt(u16, runs[4..6], 4, .little);
+ try testing.expectError(error.Overlong, (Frame{
+ .kind = .diff,
+ .cols = cols,
+ .rows = rows,
+ .cursor = null,
+ .nruns = 1,
+ .runs = runs[0..7],
+ }).apply(&grid));
+
+ // A zero-length run says nothing and is not something the encoder emits.
+ std.mem.writeInt(u32, runs[0..4], 0, .little);
+ std.mem.writeInt(u16, runs[4..6], 0, .little);
+ try testing.expectError(error.BadValue, (Frame{
+ .kind = .diff,
+ .cols = cols,
+ .rows = rows,
+ .cursor = null,
+ .nruns = 1,
+ .runs = runs[0..6],
+ }).apply(&grid));
+
+ // A run count larger than the grid has cells is refused at DECODE, before
+ // it is used to bound the walk.
+ var head: [frame_head]u8 = undefined;
+ var w: Writer = .init(&head);
+ try w.putByte(@intFromEnum(FrameKind.diff));
+ try w.putU16(cols);
+ try w.putU16(rows);
+ try putCursor(&w, null, cols, rows);
+ try w.putU32(9);
+ try testing.expectError(error.Overlong, decodeServer(@intFromEnum(ServerTag.frame), w.written()));
+
+ // ...and a grid of the wrong size is the receiver's own bug, not a frame
+ // it should paint half of.
+ var small: [4]pardes.Cell = @splat(.{});
+ try testing.expectError(error.BadValue, (Frame{
+ .kind = .full,
+ .cols = cols,
+ .rows = rows,
+ .cursor = null,
+ .nruns = 0,
+ .runs = "",
+ }).apply(&small));
+}
+
+test "detached wire: a cursor outside the grid is refused, not painted" {
+ // The one field of a frame a frontend indexes with rather than copies:
+ // tty.zig moves the terminal's own cursor to `cursor.x`/`cursor.y`, and
+ // the ESP32-P4 panel writes the cell there. A frame that says 4x2 and puts
+ // the cursor at (9,0) is an out-of-bounds write in every frontend, so it
+ // has to be a decode error and not a clamp — a clamped cursor is a wrong
+ // screen that nobody reports.
+ const cols: u16 = 4;
+ const rows: u16 = 2;
+ var head: [frame_head]u8 = undefined;
+ for ([_][2]u16{ .{ cols, 0 }, .{ 0, rows }, .{ 0xffff, 0xffff } }) |xy| {
+ var w: Writer = .init(&head);
+ try w.putByte(@intFromEnum(FrameKind.diff));
+ try w.putU16(cols);
+ try w.putU16(rows);
+ // Written by hand: `putCursor` now refuses this too, which is the
+ // other half of the same fix and is asserted below.
+ try w.putBool(true);
+ try w.putU16(xy[0]);
+ try w.putU16(xy[1]);
+ try w.putBool(false);
+ try w.putU32(0);
+ try testing.expectError(
+ error.BadValue,
+ decodeServer(@intFromEnum(ServerTag.frame), w.written()),
+ );
+ }
+ // The last cell IS in the grid, and a frame carrying it decodes.
+ {
+ var w: Writer = .init(&head);
+ try w.putByte(@intFromEnum(FrameKind.diff));
+ try w.putU16(cols);
+ try w.putU16(rows);
+ try putCursor(&w, .{ .x = cols - 1, .y = rows - 1, .bar = true }, cols, rows);
+ try w.putU32(0);
+ const got = (try decodeServer(@intFromEnum(ServerTag.frame), w.written())).frame;
+ try testing.expectEqual(@as(u16, cols - 1), got.cursor.?.x);
+ try testing.expectEqual(@as(u16, rows - 1), got.cursor.?.y);
+ }
+ // ...and the ENCODER refuses to build one, so a core bug is a dropped
+ // frame with a log line rather than a frame every frontend hangs up over.
+ var cells: [8]pardes.Cell = @splat(.{});
+ var buf: [512]u8 = undefined;
+ try testing.expectError(
+ error.BadValue,
+ encodeFrame(&buf, cols, rows, .{ .x = cols, .y = 0, .bar = false }, &cells, &.{}),
+ );
+ // A grid past the protocol's own ceiling is refused by the encoder for the
+ // same reason: `max_payload` is derived from it, so the decoder would.
+ try testing.expectError(
+ error.Overlong,
+ encodeFrame(&buf, max_cols + 1, 1, null, cells[0..0], &.{}),
+ );
+}
+
+test "detached wire: values a field cannot mean are refused" {
+ var scratch: Scratch = .{};
+
+ // A bool is 0 or 1. `2` used to be "true" in every hand-written codec that
+ // ever silently accepted a corrupt stream.
+ try testing.expectError(error.BadValue, decodeClient(
+ @intFromEnum(ClientTag.key),
+ &.{ 'a', 0, 0, 0, 0, 0, 2, 0, 0 },
+ &scratch,
+ ));
+ // A codepoint past Unicode's last: @intCast into Key.cp's u21 would panic.
+ try testing.expectError(error.BadValue, decodeClient(
+ @intFromEnum(ClientTag.key),
+ &.{ 0x00, 0x00, 0x11, 0x00, 0, 0, 0, 0, 0 },
+ &scratch,
+ ));
+ // A pane the core cannot index.
+ try testing.expectError(error.BadValue, decodeClient(
+ @intFromEnum(ClientTag.eof),
+ &.{pardes.MAX_PANES},
+ &scratch,
+ ));
+ // A zero-column grid would collapse a shared session; an over-wide one is
+ // past what this protocol carries.
+ try testing.expectError(error.BadValue, decodeClient(
+ @intFromEnum(ClientTag.hello),
+ &.{ 1, 0, 0, 0, 24, 0 },
+ &scratch,
+ ));
+ {
+ var hello: [6]u8 = undefined;
+ std.mem.writeInt(u16, hello[0..2], version, .little);
+ std.mem.writeInt(u16, hello[2..4], max_cols + 1, .little);
+ std.mem.writeInt(u16, hello[4..6], 24, .little);
+ try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.hello), &hello, &scratch));
+ }
+ // A NaN scroll distance. `pinch` multiplies into a zoom the pane keeps.
+ {
+ var pinch: [4]u8 = undefined;
+ std.mem.writeInt(u32, &pinch, @bitCast(std.math.nan(f32)), .little);
+ try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.pinch), &pinch, &scratch));
+ std.mem.writeInt(u32, &pinch, @bitCast(std.math.inf(f32)), .little);
+ try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch, &scratch));
+ }
+ // More pipe outputs than the core has selections to produce them.
+ {
+ var head: [7]u8 = undefined;
+ std.mem.writeInt(u32, head[0..4], 1, .little);
+ head[4] = 1;
+ std.mem.writeInt(u16, head[5..7], pardes.MAX_SELS + 1, .little);
+ try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.pipe_resp), &head, &scratch));
+ }
+ // A cell with the reserved attribute bit set, and one with an impossible
+ // grapheme length. Both are bytes this protocol has no meaning for.
+ {
+ var grid: [1]pardes.Cell = @splat(.{});
+ // default=0, len=1, 'x', fg default, bg default, attrs=0x80, ul, font
+ var runs = [_]u8{ 0, 0, 0, 0, 1, 0, 0, 1, 'x', 0, 0, 0x80, 0, 0 };
+ const f: Frame = .{ .kind = .diff, .cols = 1, .rows = 1, .cursor = null, .nruns = 1, .runs = &runs };
+ try testing.expectError(error.BadValue, f.apply(&grid));
+ runs[11] = 0;
+ runs[7] = 8; // len past Cell.text
+ try testing.expectError(error.BadValue, f.apply(&grid));
+ runs[7] = 0; // ...and a grapheme of no bytes at all
+ try testing.expectError(error.BadValue, f.apply(&grid));
+ }
+}
+
+test "detached wire: max_payload bounds every message this protocol can build" {
+ // The derivation in `max_payload`'s comment, asserted: the worst frame
+ // this protocol admits fits, so a receiver sized for max_payload can
+ // always hold one.
+ try testing.expect(frameBound(max_cols, max_rows) <= max_payload);
+ // ...and a 4 MiB paste, which is the tty frontend's own cap.
+ try testing.expect((4 << 20) + header_len + 4 <= max_payload);
+ // A slice past the width of its own length prefix is refused by the
+ // ENCODER, rather than written and rejected at the far end. A command line
+ // is u16-prefixed because nothing legitimately types 64 KiB of one.
+ const gpa = testing.allocator;
+ const huge = try gpa.alloc(u8, std.math.maxInt(u16) + 1);
+ defer gpa.free(huge);
+ @memset(huge, 'x');
+ const room = try gpa.alloc(u8, huge.len + header_len + 2);
+ defer gpa.free(room);
+ try testing.expectError(error.Overlong, encodeClient(room, .{ .event = .{ .command = huge } }));
+ // ...and a buffer the caller sized too small is NoSpace, which is the
+ // server's signal to drop that one message rather than the client.
+ var small: [8]u8 = undefined;
+ try testing.expectError(error.NoSpace, encodeClient(&small, .{ .event = .{ .paste = "0123456789" } }));
+}
diff --git a/src/dump.zig b/src/dump.zig
index 37ba83c0..f8740cdb 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
+const limits = @import("limits.zig");
// scoped, not bare std.log: main.zig's logFn drops the unscoped .default scope
// wholesale (ghostty and uucode log there too), and a corrupt dump's parse
@@ -40,22 +41,11 @@ pub const magic = "pardes-dump";
pub const version: u32 = 1;
pub const max_panes: usize = 16;
pub const max_cols: usize = 6;
-/// Bounds the only user-editable, schema-owned tag fragment. Keep this beside
-/// the dump limits so readers can reject data before copying it into a pane.
-/// 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.
-///
-/// 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 (@import("pardes_config").platform == .p4) 512 else 4096;
/// Output arguments are typed in the same bounded one-line tag storage. Keep
/// the schema limit named independently so a dump reader can validate it
-/// without importing the output-pane implementation.
-pub const max_origin_arg: usize = max_tag_tail;
+/// without importing the output-pane implementation. The bound itself is
+/// `limits.max_tag_tail` — the one place a board-shaped capacity is chosen.
+pub const max_origin_arg: usize = limits.max_tag_tail;
pub const Size = struct {
cols: u16,
@@ -157,7 +147,7 @@ pub fn validate(state: State) !void {
if (!std.math.isFinite(pane.vweight) or pane.vweight <= 0)
return error.BadDumpPaneWeight;
if (pane.scroll > std.math.maxInt(i32)) return error.BadDumpPaneScroll;
- if (pane.tag_tail) |tail| if (tail.len > max_tag_tail)
+ if (pane.tag_tail) |tail| if (tail.len > limits.max_tag_tail)
return error.BadDumpTagTail;
if (pane.file) |file| if (file.origin_arg.len > max_origin_arg)
return error.BadDumpOriginArg;
@@ -412,9 +402,9 @@ test "validation bounds pane restore state" {
try std.testing.expectError(error.BadDumpPaneScroll, validate(state));
panes[0].scroll = 0;
- var oversized_tail: [max_tag_tail + 1]u8 = @splat('x');
+ var oversized_tail: [limits.max_tag_tail + 1]u8 = @splat('x');
panes[0].tag_tail = &oversized_tail;
try std.testing.expectError(error.BadDumpTagTail, validate(state));
- panes[0].tag_tail = oversized_tail[0..max_tag_tail];
+ panes[0].tag_tail = oversized_tail[0..limits.max_tag_tail];
try validate(state);
}
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index a01f1177..12f8dd46 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -175,12 +175,12 @@ pub fn forSetting(setting: runtime_config.Setting) ?[]const Segment {
.tty => &tty_panel,
.gui => &gui_panel,
.macos => &mac_panel,
- .web, .p4 => null,
+ .web, .esp32p4 => null,
},
.scene => switch (backend) {
.gui => &gui_scene,
.macos => &mac_scene_segments,
- .tty, .web, .p4 => null,
+ .tty, .web, .esp32p4 => null,
},
else => null,
};
diff --git a/src/p4.zig b/src/esp32p4.zig
index 8e46b2ff..8eab57e2 100644
--- a/src/p4.zig
+++ b/src/esp32p4.zig
@@ -1,7 +1,7 @@
//! The ESP32-P4 firmware shell: pardes as one freestanding object, bytes in and bytes out.
//!
//! This is the fourth platform, and the only one that is not an executable. `zig build
-//! -Dplatform=p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C
+//! -Dplatform=esp32p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C
//! ABI below; the `zig-p4` package links it beside its own `_start`, its generated linker script,
//! and its UART driver. Nothing here knows what a UART is.
//!
@@ -89,15 +89,15 @@ fn panicImpl(msg: []const u8, _: ?usize) noreturn {
// ---------------------------------------------------------------------------------- the C ABI
//
// Deliberately tiny, and versioned. Linkers do not type-check C symbols, so a signature that drifts
-// on one side of this seam links cleanly and then corrupts the stack. `pardes_p4_abi_version` is the
+// on one side of this seam links cleanly and then corrupts the stack. `pardes_esp32p4_abi_version` is the
// cheapest possible defence: the firmware calls it first and refuses to continue on a mismatch.
/// Bumped whenever any signature below changes, including a type.
-/// 2 added `GpioFn` to `pardes_p4_init`. A firmware built against 1 passes five arguments where six
+/// 2 added `GpioFn` to `pardes_esp32p4_init`. A firmware built against 1 passes five arguments where six
/// are read, which is exactly the silent-corruption case this counter exists to turn into a message.
const abi_version: u32 = 2;
-export fn pardes_p4_abi_version() callconv(.c) u32 {
+export fn pardes_esp32p4_abi_version() callconv(.c) u32 {
return abi_version;
}
@@ -226,14 +226,14 @@ var dirty: bool = true;
/// and with `direct_emit` neither is ever read - the emitter diffs against the Surface and writes
/// the wire itself - so `init` sizes vaxis to a single cell and those two grids cost nothing.
///
-/// Set with `-Dp4-cols` / `-Dp4-rows`, because the ceiling is a measurement rather than a constant
+/// Set with `-Desp32p4-cols` / `-Desp32p4-rows`, because the ceiling is a measurement rather than a constant
/// and it moves for two independent reasons: the 384 KiB heap, and the round trip, which grows with
/// the cell count because every frame walks the whole grid. See the geometry table in
/// `05-zig-p4/experiments/report.typ` for both curves.
///
/// Raising these further is what PSRAM would buy: this board has 32 MB fitted and untrained.
-pub const max_cols: u16 = @import("pardes_config").p4_cols;
-pub const max_rows: u16 = @import("pardes_config").p4_rows;
+pub const max_cols: u16 = @import("pardes_config").esp32p4_cols;
+pub const max_rows: u16 = @import("pardes_config").esp32p4_rows;
var cur_winsize: vaxis.Winsize = .{ .rows = max_rows, .cols = max_cols, .x_pixel = 0, .y_pixel = 0 };
@@ -256,7 +256,7 @@ fn vaxisSize() vaxis.Winsize {
/// Hand over the allocator and the output sink, state the initial window size, and bring the editor
/// up. Returns 0, or a small non-zero code the firmware can only report.
-export fn pardes_p4_init(
+export fn pardes_esp32p4_init(
alloc: *const Allocator,
write: WriteFn,
gpio: ?GpioFn,
@@ -326,7 +326,7 @@ export fn pardes_p4_init(
/// Raw bytes off the wire: keystrokes, capability replies, and in-band resize reports. All three are
/// the same kind of thing to `vaxis.Parser`, and this function does not distinguish them.
-export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void {
+export fn pardes_esp32p4_input(ptr: [*]const u8, len: usize) callconv(.c) void {
const c = core orelse return;
// Append, dropping the oldest on overflow: a full buffer means the parser is stuck on a
@@ -363,7 +363,7 @@ export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void {
/// yet", and the loop below keeps those bytes. Only the one-byte case needed an answer, because it is
/// the only one the parser answers wrongly instead of declining.
///
-/// `force` is how a real Escape keypress still works: `pardes_p4_tick` calls with it set once the
+/// `force` is how a real Escape keypress still works: `pardes_esp32p4_tick` calls with it set once the
/// hold has lasted longer than any serial line would take to deliver the next byte.
fn drainInput(c: *pardes.Pardes, force: bool) void {
var off: usize = 0;
@@ -559,7 +559,7 @@ fn mapKey(cp: u21) u21 {
else => cp,
};
}
-export fn pardes_p4_tick(now_ms: u64) callconv(.c) void {
+export fn pardes_esp32p4_tick(now_ms: u64) callconv(.c) void {
const c = core orelse return;
last_now_ms = now_ms;
// The held Escape, released. Anything still waiting after this long is a key the human pressed,
@@ -582,26 +582,26 @@ export fn pardes_p4_tick(now_ms: u64) callconv(.c) void {
/// How long a lone ESC waits for a second byte before it counts as the Escape key.
const esc_hold_ms = 10;
-/// The last timestamp `pardes_p4_tick` was given, so `drainInput` can date a hold without needing a
+/// The last timestamp `pardes_esp32p4_tick` was given, so `drainInput` can date a hold without needing a
/// clock of its own - there is no clock on this side of the ABI.
var last_now_ms: u64 = 0;
/// When the buffer became a lone ESC, or null when it is not holding one.
var esc_held_at: ?u64 = null;
-export fn pardes_p4_wants_frame() callconv(.c) bool {
+export fn pardes_esp32p4_wants_frame() callconv(.c) bool {
const c = core orelse return false;
return dirty or c.animationActive();
}
-export fn pardes_p4_render() callconv(.c) u32 {
+export fn pardes_esp32p4_render() callconv(.c) u32 {
const c = core orelse return 0;
c.pump(.{ .ctx = null, .vtable = &pardes_host }) catch |err| return errCode(err);
dirty = false;
return 0;
}
-export fn pardes_p4_quit() callconv(.c) bool {
+export fn pardes_esp32p4_quit() callconv(.c) bool {
const c = core orelse return true;
return c.quit;
}
@@ -982,7 +982,7 @@ inline fn cycles() u64 {
}
/// The last frame's three stages, in cycles. Zero on any platform without the CSR.
-export fn pardes_p4_frame_prof(copy: *u64, render: *u64, flush: *u64) callconv(.c) void {
+export fn pardes_esp32p4_frame_prof(copy: *u64, render: *u64, flush: *u64) callconv(.c) void {
copy.* = prof_copy_cy;
render.* = prof_render_cy;
flush.* = prof_flush_cy;
diff --git a/src/esp32p4/app.zig b/src/esp32p4/app.zig
new file mode 100644
index 00000000..a9cf627d
--- /dev/null
+++ b/src/esp32p4/app.zig
@@ -0,0 +1,546 @@
+//! pardes, as ESP32-P4 firmware: the reset entry, the heap, the clock, the trap handler and the
+//! loop.
+//!
+//! There is no operating system under this. `_start` is the reset entry the second-stage bootloader
+//! jumps to, and this file is the entire platform: a heap, a millisecond clock, and UART0.
+//!
+//! ## Why the firmware root is in the editor's repository
+//!
+//! It was written in the `05-zig-p4` toolchain repository, next to the SoC support it uses, and it
+//! moved here because everything in it is a statement about the EDITOR. The heap span it hands over
+//! is the number that decides how large a grid the board can drive; `input_chunk` is sized against
+//! what applying one keystroke costs in `src/pardes.zig`; the loop's shape - read, chunk, tick,
+//! render only when dirty - is this editor's loop and no one else's; and the `-Dprof` attribution
+//! exists to answer "where did the 34 ms of a keystroke go" about this program. A firmware root that
+//! specific to one application belongs beside it.
+//!
+//! What stayed behind is everything a second application would also want, and none of it is
+//! duplicated here: the SoC and HAL, the translate-c register layer, the coalescing heap, `std.Io`
+//! for this chip, the app descriptor, the generated linker script, the image builder, the flasher
+//! and the interactive console. Those arrive as the `zig_p4` dependency, and this file imports
+//! exactly four of its modules - `soc`, `hal`, `heap` and `config` - plus two sibling files,
+//! `uart.zig` and `input_rescue.zig`, which are the editor's own.
+//!
+//! ## Where the editor is
+//!
+//! On the far side of a C ABI, still, and that is a choice rather than a leftover. `src/esp32p4.zig` in
+//! this same repository is compiled as ONE freestanding object (`b.addObject`, rooted at that file)
+//! and linked in beside this one; the `extern` declarations below are the near side of that seam.
+//!
+//! Importing `esp32p4.zig` as a module instead would be shorter to write and worse in every way that
+//! matters. It would drag the core's whole module graph - vaxis, the themes, the allocator tiers -
+//! into this root, which is the compilation that must stay small enough to reason about. It would
+//! give the firmware two ways to reach the editor. And above all it would make the OBJECT path a
+//! second arrangement, tested separately: that path is what `05-zig-p4 -Dpardes -Dpardes-obj=...`
+//! builds, it is what every measurement in that repository's `experiments/` was taken through, and
+//! it is a supported way to build this board. With the extern kept, both builds link the same eight
+//! symbols against the same object file, so neither can drift and neither is the better-tested one.
+//! The reasons the seam is a file at all - a nested `build.zig.zon` dependency broke every build in
+//! the toolchain repository - are recorded in `src/esp32p4.zig:8-15` and `05-zig-p4/build.zig:238-260`.
+//!
+//! Who owns which symbol: `src/esp32p4.zig` exports all eight `pardes_esp32p4_*` functions and nothing else.
+//! This file exports `_start`, `zig_main`, `trapEntry` and `trapReport`. `esp_app_desc` belongs to
+//! neither and comes from the toolchain's own appdesc object, which the link adds unconditionally.
+//! `abi_version` below is the one constant both sides spell, and its counterpart is
+//! `src/esp32p4.zig:98` - one repository now, so a bump is two lines in one diff rather than two commits
+//! in two trees.
+//!
+//! The seam is deliberately **bytes in, bytes out**. Everything that needs to know what a cell is -
+//! vaxis, the ANSI encoder, the input parser, the capability handshake - lives on the far side,
+//! next to the vaxis it is built against. What crosses is a byte stream in each direction, which is
+//! exactly what a serial line is, so this file has no opinion about terminals at all.
+//!
+//! ## Where the memory is
+//!
+//! Measured on this die by the toolchain's `examples/memprobe.zig`, not read off a datasheet, and
+//! written down once in the generated linker script (`05-zig-p4/build.zig:1572,1579,1584-1585`):
+//!
+//! 0x4FF00000..0x4FF3F000 252 KiB `l2mem`: .data/.bss/.stack are linked into this
+//! 0x4FF3F000..0x4FF40000 4 KiB mask ROM .data/.bss - untouchable, ets_printf needs it
+//! 0x4FF40000..0x4FFA0000 384 KiB `l2high`: handed to the editor as its entire heap
+//! 0x4FFA0000..0x4FFC0000 128 KiB NOT memory - the L2 cache lives here
+//!
+//! That last line is why the heap is 384 KiB and not the 512 KiB an earlier version of this comment
+//! claimed. The first probe wrote a pattern and read it back one page at a time and reported the
+//! whole upper 512 KiB as RAM, because a store followed immediately by a load of the SAME address
+//! returns the stored value whether the backing store is real, an address mirror, or merely a dirty
+//! cache line. Writing every page before reading any page separates the three, and the top 128 KiB
+//! then failed; handing them to an allocator hung the heap on its first free-list walk. ESP-IDF's
+//! own arithmetic agrees exactly: SRAM_HIGH_SIZE = 0x80000 - CONFIG_CACHE_L2_CACHE_SIZE, with the
+//! Kconfig default of 128 KiB.
+//!
+//! The span arrives as `__heap_start`/`__heap_end` from that script, so those addresses are written
+//! down in exactly one place. The editor owns it outright: it is passed in at init and this file
+//! never allocates from it.
+//!
+//! PSRAM is not used. The board has 32 MB fitted and it would make all of this comfortable, but
+//! ESP-IDF's own ESP32-P4 implementation runs past a thousand lines - MPLL, MSPI clocking, pin
+//! drive and DQS, CS timing, mode registers, a connectivity check, and an entire timing-calibration
+//! subsystem - and the mask ROM offers only MMU mapping, no device init. Touching it untrained
+//! faults and hangs the core, which `examples/memprobe.zig` demonstrates on purpose.
+
+const std = @import("std");
+const soc = @import("soc");
+const config = @import("config");
+
+/// `-Dprof`: time the two phases of a keystroke on the board and print the cycle counts. A
+/// diagnostic, not a feature - see the loop.
+const prof = config.prof;
+
+/// Every byte this loop has taken off the UART, for `-Dprof`. Ground truth for "did the burst
+/// arrive", which a screen reconstruction cannot answer: a character can be missing from the screen
+/// because it never arrived, because the editor never applied it, or because the viewport does not
+/// show that column.
+var rx_total: u32 = 0;
+
+/// How many input bytes to hand the editor before draining the receiver again. Chosen against the
+/// FIFO rather than against the editor: applying one keystroke was measured at 44 us on an empty
+/// line and 63 us at 640 characters, so eight of them is at most ~0.5 ms in which nothing empties
+/// the receiver, against a 128-byte FIFO that holds 11 ms of wire at 115200. Twenty times the margin
+/// needed, and it costs nothing on the wire because one render still happens per loop iteration.
+const input_chunk = 8;
+const hal = @import("hal");
+const heapmod = @import("heap");
+const uart = @import("uart.zig");
+
+// ------------------------------------------------------------------------------------- the ABI
+// Eight functions, all `callconv(.c)`, all implemented in the linked object - `src/esp32p4.zig` in this
+// repository, compiled for the same target and exporting exactly these names. This is the complete
+// interface between this board and the editor, and it is deliberately bytes-and-memory only: the
+// editor never learns what a UART is, and this file never learns what a cell is.
+//
+// The declarations below are a SECOND spelling of the signatures in `src/esp32p4.zig:100-127,259-...`,
+// and that duplication is what a C ABI is: each side declares the wire independently, which is
+// precisely why `abi_version` has to be checked. Sharing a Zig type between them would mean sharing
+// a module, which would mean the core in this compilation - see the header.
+
+/// How the editor emits bytes. Called with finished runs of ANSI, many times per frame.
+const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void;
+
+/// The board's pads, offered to the editor. Optional on the wire so a firmware with nothing to
+/// toggle passes null and the `Gpio` word reports that rather than the object guessing.
+const GpioFn = *const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool;
+
+/// This board's allocator, handed across as plain function pointers. `log2_align` is a log2 value,
+/// which is exactly how `std.mem.Alignment` represents itself, so neither side needs a conversion
+/// table.
+///
+/// The memory belongs to THIS side: only the firmware knows that the heap is the 384 KiB at
+/// 0x4FF40000, that the 128 KiB above it is L2 cache, and that PSRAM is untrained. The editor gets
+/// an allocator, not an address range.
+const Allocator = extern struct {
+ ctx: ?*anyopaque,
+ alloc: *const fn (ctx: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8,
+ resize: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool,
+ free: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void,
+};
+
+/// The one number both sides must agree on. Linkers do not type-check C symbols, so a signature
+/// that drifts on one side of this seam links cleanly and then corrupts the stack; checking this
+/// before calling anything else turns that into a refusal to boot.
+const abi_version: u32 = 2;
+extern fn pardes_esp32p4_abi_version() callconv(.c) u32;
+
+/// Hand over the allocator and the output sink, and state the initial window size. Returns 0, or a
+/// small non-zero code this file can only report.
+extern fn pardes_esp32p4_init(
+ alloc: *const Allocator,
+ write: WriteFn,
+ gpio: ?GpioFn,
+ ctx: ?*anyopaque,
+ cols: u16,
+ rows: u16,
+) callconv(.c) u32;
+
+/// Raw bytes off the wire: keystrokes, capability-query replies, and the host bridge's in-band
+/// resize reports. The editor parses all three; this file distinguishes none of them.
+extern fn pardes_esp32p4_input(ptr: [*]const u8, len: usize) callconv(.c) void;
+
+/// Advance time. Separate from `input` because animations and timeouts must progress on a wire
+/// where nothing is arriving.
+extern fn pardes_esp32p4_tick(now_ms: u64) callconv(.c) void;
+
+/// Emit one frame through the write callback. Returns 0 or an error code.
+extern fn pardes_esp32p4_render() callconv(.c) u32;
+
+/// Is there anything to draw - a dirty surface or a running animation? Asked every iteration so a
+/// quiet editor costs no bytes on a 115200-baud link.
+extern fn pardes_esp32p4_wants_frame() callconv(.c) bool;
+
+/// Has the user asked to leave? There is nowhere to go, so this only stops the loop.
+extern fn pardes_esp32p4_quit() callconv(.c) bool;
+
+/// The last frame's three stages in CPU cycles: the copy of pardes's Surface into vaxis's grid,
+/// vaxis's own diff-and-emit, and the push into the UART. Only meaningful under `-Dprof`; the
+/// editor object always exports it, and it costs two CSR reads per stage.
+extern fn pardes_esp32p4_frame_prof(copy: *u64, render: *u64, flush: *u64) callconv(.c) void;
+
+// ------------------------------------------------------------------------------------ the sink
+
+/// The write callback handed to `pardes_esp32p4_init`. No context is needed - there is one UART.
+fn writeOut(_: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void {
+ uart.write(ptr[0..len]);
+}
+
+/// Flip one pad and report the level before and after. The editor's `Gpio` word calls this; the
+/// editor has no register of its own for it, deliberately.
+///
+/// THIS IS WHY THE SEAM IS HERE. A toggle is not a write to GPIO_OUT: `configureOutput` points the
+/// pad's IO MUX at the GPIO function, routes the GPIO matrix's output to it, sets the drive strength
+/// and input buffer and clears the pulls, and only then enables the driver - four register files,
+/// indexed by a per-pin table. That code already exists in the toolchain package's `src/hal/gpio.zig`,
+/// it is the same call that package's `src/main.zig` blinks with, and its register numbers are
+/// checked against ESP-IDF's own headers by `zig build diff` there. A second copy inside the editor
+/// object would be a second copy under no test.
+///
+/// `getDrivenLevel` rather than `getLevel`: the answer is the level this board is DRIVING, which is
+/// defined for every pin. The pad's own level is what the outside world says, and on an unconnected
+/// header pin that is noise. The input buffer is enabled anyway, so `Peek` of GPIO_IN_REG shows the
+/// pad for anyone who wants to compare the two.
+fn gpioToggle(_: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool {
+ if (pin > hal.gpio.max_pin) return false;
+ const p: u8 = @intCast(pin);
+ hal.gpio.configureOutput(p, .{ .readback = true });
+ const before = hal.gpio.getDrivenLevel(p);
+ if (before == 1) hal.gpio.setLow(p) else hal.gpio.setHigh(p);
+ was.* = before;
+ now.* = hal.gpio.getDrivenLevel(p);
+ return true;
+}
+
+// ------------------------------------------------------------------------------------- the heap
+
+/// The span the linker script hands over, from `l2high`'s ORIGIN and LENGTH.
+///
+/// Reached with `@extern`, NOT with `extern const __heap_start: anyopaque` plus
+/// `@intFromPtr`/`@ptrFromInt`. That spelling was here first and it was silently wrong: declaring a
+/// linker symbol as an `anyopaque` OBJECT gives the optimiser a zero-sized object, so a pointer
+/// derived from its address carries provenance for zero bytes, and ordinary (non-volatile) stores
+/// through it are dead code it may drop. The toolchain's `examples/heapcheck.zig` caught it on the
+/// die - the allocator's first block header read back as `size=2988759312 next=0xffffffff`-not, and
+/// the free list walk never terminated. A `[*]u8` from `@extern` has no size to lose.
+const heap_start = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_start" });
+const heap_end = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_end" });
+
+fn heapSpan() []align(heapmod.Heap.granule) u8 {
+ return heap_start[0 .. @intFromPtr(heap_end) - @intFromPtr(heap_start)];
+}
+
+/// The one heap. A K&R coalescing free list over that span, validated on this die by the toolchain's
+/// `examples/heapcheck.zig`: 512 blocks fill and free back to a single 393,216-byte block, a holed
+/// arena still satisfies a 4 KiB request, and 20,000 random operations drain back to one block.
+var gpa_heap: heapmod.Heap = undefined;
+
+// The four C forwarders the editor is handed. `log2_align` round-trips through
+// `std.mem.Alignment`, whose representation IS the log2 value.
+
+fn cAlloc(_: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8 {
+ const a = gpa_heap.allocator();
+ return a.vtable.alloc(a.ptr, len, @enumFromInt(log2_align), @returnAddress());
+}
+
+fn cResize(_: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool {
+ const a = gpa_heap.allocator();
+ return a.vtable.resize(a.ptr, ptr[0..len], @enumFromInt(log2_align), new_len, @returnAddress());
+}
+
+fn cFree(_: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void {
+ const a = gpa_heap.allocator();
+ a.vtable.free(a.ptr, ptr[0..len], @enumFromInt(log2_align), @returnAddress());
+}
+
+const editor_allocator: Allocator = .{
+ .ctx = null,
+ .alloc = cAlloc,
+ .resize = cResize,
+ .free = cFree,
+};
+
+// ------------------------------------------------------------------------------------ the clock
+
+/// Milliseconds since boot, off the systimer - a 16 MHz counter (the toolchain package's
+/// `src/hal/systimer.zig:31`), which is the cheapest trustworthy clock on this chip. `read` returns
+/// null if the unit is not running, in which case time simply does not advance and the editor stops
+/// animating; that is a better failure than a clock that jumps.
+fn nowMs() u64 {
+ const us = hal.systimer.micros(.unit0) orelse return 0;
+ return us / 1000;
+}
+
+// ------------------------------------------------------------------------------------- the loop
+
+export fn zig_main() noreturn {
+ // FIRST, before a single byte of `.rodata` is touched - which means before the marker below,
+ // because that marker IS a string literal in flash and would read as machine code without this.
+ soc.flushFlashCache();
+ const heap = heapSpan();
+ soc.rom.print("\r\nMARK B3 rom.print heap 0x%08x..0x%08x %u KiB\r\n", .{
+ @as(u32, @intFromPtr(heap.ptr)),
+ @as(u32, @intFromPtr(heap.ptr)) + @as(u32, @intCast(heap.len)),
+ @as(u32, @intCast(heap.len / 1024)),
+ });
+
+ // The CPU clock, before anything is timed against it. The bootloader leaves 90 MHz and the
+ // CPLL is already at 360, so this is a divider change that disturbs neither UART0 (XTAL) nor
+ // the systimer (XTAL/2.5) nor the flash interface (SPLL). See the toolchain package's
+ // `src/hal/clkrst.zig:setCpuFreq`.
+ if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) {
+ 180 => .mhz180,
+ 360 => .mhz360,
+ else => .mhz90,
+ });
+
+ const rwdt_was_armed = hal.rwdt.disable();
+ hal.systimer.init();
+ _ = rwdt_was_armed;
+
+ const their_abi = pardes_esp32p4_abi_version();
+ if (their_abi != abi_version) {
+ uart.write("MARK PARDES_ABI_MISMATCH\r\n");
+ while (true) {}
+ }
+
+ gpa_heap = heapmod.Heap.init(heap);
+ _ = uart.drainInput();
+
+ // Ask for more than any grid this board will ever render, so the SHELL's own ceiling is what
+ // governs - it clamps to `-Desp32p4-cols`/`-Desp32p4-rows` and reports the result. Naming 80x24 here made
+ // the firmware a second opinion about the geometry, which is one opinion too many.
+ const rc = pardes_esp32p4_init(&editor_allocator, writeOut, gpioToggle, null, 255, 255);
+
+ if (rc != 0) {
+ soc.rom.print("MARK PARDES_INIT_FAIL rc=%u\r\n", .{rc});
+ const s = gpa_heap.stats();
+ soc.rom.print("MARK PARDES_HEAP free=%u largest=%u blocks=%u\r\n", .{
+ s.free, s.largest_free, s.free_blocks,
+ });
+ while (true) {}
+ }
+
+ // The HEAP, after the editor has taken what it needs. This is the number that decides how large
+ // a grid the board can drive, so it is printed on every boot rather than only on failure: a
+ // geometry that fits with 2 KB to spare and one that fits with 80 KB are not the same answer,
+ // and the difference is invisible from the host otherwise.
+ {
+ const s = gpa_heap.stats();
+ soc.rom.print("MARK PARDES_HEAP free=%u largest=%u blocks=%u\r\n", .{
+ s.free, s.largest_free, s.free_blocks,
+ });
+ }
+
+ // The CPU clock, measured rather than assumed. Every cycle count this firmware reports is
+ // divided by it somewhere, and the toolchain's `src/io/chip.zig` records it as "a measured
+ // ~90 MHz" that nothing here reconfigures - so it is worth printing rather than remembering. The
+ // systimer is XTAL/2.5 = 16 MHz and is NOT derived from the CPU clock
+ // (`src/hal/systimer.zig:31`, `clk_tree_defs.h:196-198`), which is exactly what makes it a valid
+ // reference for measuring it.
+ if (prof) {
+ const t_start = hal.systimer.micros(.unit0) orelse 0;
+ const c_start = soc.cycles();
+ // 50 ms is long enough that the systimer's 16 MHz granularity and the loop's own overhead
+ // are both noise, and short enough to be invisible in a boot.
+ while ((hal.systimer.micros(.unit0) orelse 0) -% t_start < 50_000) {}
+ const elapsed_us = (hal.systimer.micros(.unit0) orelse 0) -% t_start;
+ const elapsed_cy = soc.cycles() - c_start;
+ soc.rom.print("MARK CPU_HZ cycles=%u us=%u khz=%u\r\n", .{
+ @as(u32, @intCast(elapsed_cy)),
+ @as(u32, @intCast(elapsed_us)),
+ @as(u32, @intCast(if (elapsed_us > 0) elapsed_cy * 1000 / elapsed_us else 0)),
+ });
+ }
+ soc.rom.print("MARK PARDES_READY\r\n", .{});
+
+ var in: [256]u8 = undefined;
+ while (!pardes_esp32p4_quit()) {
+ // ATTRIBUTION. The host can time a keystroke's round trip but cannot see what the firmware
+ // spent it on, and the two candidates - parsing and editing, versus rendering - want
+ // opposite fixes. `soc.cycles()` is the unprivileged cycle counter, so this costs two CSR
+ // reads per phase and quantises at one cycle, which is four orders of magnitude below the
+ // milliseconds being attributed. Gated on `prof` so the shipping build carries none of it.
+ const n = uart.read(&in);
+ rx_total +%= @intCast(n);
+
+ var input_cy: u64 = 0;
+ if (n > 0) {
+ const t0 = if (prof) soc.cycles() else 0;
+ // IN CHUNKS, rescuing the receiver between them. Applying a keystroke is not free and
+ // gets dearer as the line grows - measured at 44 us on an empty line and 63 us at 640
+ // characters - so handing over a full 128-byte batch is up to 8 ms in which nothing
+ // drains the receiver, against a FIFO that holds only 11 ms of wire. A 600-byte paste
+ // lost 93 bytes to exactly that window even with the transmitter's own rescue in place.
+ //
+ // Splitting a burst at an arbitrary byte is safe: `pardes_esp32p4_input` keeps whatever it
+ // could not parse, which is how it already survives an escape sequence split across two
+ // UART reads. One render still happens per loop iteration, so this costs no extra wire.
+ var off: usize = 0;
+ while (off < n) {
+ const chunk = @min(input_chunk, n - off);
+ pardes_esp32p4_input(in[off..].ptr, chunk);
+ off += chunk;
+ if (off < n) uart.rescueNow();
+ }
+ if (prof) input_cy = soc.cycles() - t0;
+ }
+
+ pardes_esp32p4_tick(nowMs());
+
+ // Only when there is something to show. On a link this slow an unconditional repaint per
+ // iteration would saturate the wire and starve input.
+ if (pardes_esp32p4_wants_frame()) {
+ const t0 = if (prof) soc.cycles() else 0;
+ const err = pardes_esp32p4_render();
+ if (err != 0) soc.rom.print("MARK PARDES_RENDER_FAIL rc=%u\r\n", .{err});
+ if (prof) {
+ const render_cy = soc.cycles() - t0;
+ // A SECOND render with nothing changed since the first. It splits the cost in two:
+ // whatever this still costs is the price of walking and diffing the whole editor
+ // state, paid regardless of output, while the difference between the two is the
+ // price of the change itself. `wants_frame` is false now, so this only happens
+ // under -Dprof and never on a shipping build.
+ const t1 = soc.cycles();
+ _ = pardes_esp32p4_render();
+ const idle_cy = soc.cycles() - t1;
+ // Reported in cycles, not microseconds: the divisor is the CPU clock, which this
+ // firmware does not set and has only ever measured, so converting here would bake a
+ // guess into the data. The toolchain's `experiments/` divides by the clock it
+ // measured.
+ var copy_cy: u64 = 0;
+ var vx_cy: u64 = 0;
+ var flush_cy: u64 = 0;
+ pardes_esp32p4_frame_prof(&copy_cy, &vx_cy, &flush_cy);
+ soc.rom.print("PROF in=%u render=%u idle=%u copy=%u vaxis=%u flush=%u rx=%u rxdrop=%u txdrop=%u\r\n", .{
+ @as(u32, @intCast(input_cy)),
+ @as(u32, @intCast(render_cy)),
+ @as(u32, @intCast(idle_cy)),
+ @as(u32, @intCast(copy_cy)),
+ @as(u32, @intCast(vx_cy)),
+ @as(u32, @intCast(flush_cy)),
+ rx_total,
+ uart.inputDropped(),
+ uart.dropped,
+ });
+ }
+ }
+ }
+
+ soc.rom.print("\r\nMARK PARDES_QUIT\r\n", .{});
+ while (true) {}
+}
+
+// ------------------------------------------------------------------------------------ the trap
+
+/// A trap handler, because the absence of one is why this port has been guessing.
+///
+/// The mask ROM prints "Guru Meditation" for a trap only while ITS handler is still installed;
+/// anything this image does that replaces or outgrows that path fails silently instead, and a silent
+/// fault is indistinguishable from an infinite loop over a serial line. This one reports the three
+/// registers that name the fault and then stops, using the direct-FIFO writer so it shares nothing
+/// with the editor's buffered output.
+///
+/// `mtvec` is set in DIRECT mode (low two bits zero), so every trap and every interrupt lands on
+/// `trapEntry` regardless of cause - which is what a diagnostic wants.
+export fn trapEntry() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile ("j trapReport");
+}
+
+export fn trapReport() noreturn {
+ const mcause = asm volatile ("csrr %[o], mcause"
+ : [o] "=r" (-> u32),
+ );
+ const mepc = asm volatile ("csrr %[o], mepc"
+ : [o] "=r" (-> u32),
+ );
+ const mtval = asm volatile ("csrr %[o], mtval"
+ : [o] "=r" (-> u32),
+ );
+ uart.write("\r\nMARK TRAP mcause=");
+ uart.dumpWord(mcause);
+ uart.write("MARK TRAP mepc=");
+ uart.dumpWord(mepc);
+ uart.write("MARK TRAP mtval=");
+ uart.dumpWord(mtval);
+ uart.write("MARK TRAP dropped=");
+ uart.dumpWord(uart.dropped);
+ while (true) {}
+}
+
+// --------------------------------------------------------------------------- the root's own duties
+//
+// These are the FIRMWARE root's declarations, and they are not the same set as `src/esp32p4.zig`'s: that
+// file is the root of its own object and carries its own `std_options` and `panic` for the core's
+// half of the image. Two roots, two instantiations of std, one per compilation unit - which is
+// exactly what the object seam buys, and why a panic in the core prints `PARDES_CORE_PANIC` through
+// the write callback while a panic here prints `PARDES_PANIC` through the mask ROM.
+
+/// `page_size_min`/`max`: the board has no MMU and no pages, but std derives allocator alignment
+/// from these. 4 KiB is the ESP32-P4's cache and DMA granularity.
+///
+/// `logFn` is not cosmetic. std's default log implementation reaches `std.debug_io`, which
+/// instantiates `std.Io.Threaded` - a thread pool, `getrandom`, `IOV_MAX`, `mremap` - none of which
+/// exist here, and one `log.warn` from anywhere is enough to drag all of it into the image.
+pub const std_options: std.Options = .{
+ .page_size_min = 4096,
+ .page_size_max = 4096,
+ .logFn = logFn,
+};
+
+fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ var buf: [256]u8 = undefined;
+ const line = std.fmt.bufPrint(&buf, "\r\n[" ++ level.asText() ++ "/" ++ @tagName(scope) ++ "] " ++ fmt ++ "\r\n", args) catch
+ "\r\n[log overflow]\r\n";
+ uart.write(line);
+}
+
+pub const panic = std.debug.FullPanic(panicImpl);
+
+fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn {
+ // The fixed text goes out through the ROM deliberately: a panic may BE the console writer
+ // failing, and `ets_printf` shares nothing with `uart.write` except the FIFO itself.
+ //
+ // The MESSAGE does not, and that is a correction rather than a preference. `msg` is a Zig SLICE
+ // and `%s` reads until a NUL, so handing `msg.ptr` to printf prints the message and then
+ // whatever happens to sit after it in memory until a zero byte turns up. Literals get away with
+ // it; std's own panics do not, because they are formatted into a buffer - "index out of bounds:
+ // index 5, len 3" - and carry no terminator. `uart.write` takes a length.
+ soc.rom.print("\r\nMARK PARDES_PANIC ", .{});
+ uart.write(msg);
+ // The address is what makes it actionable: addr2line against the ELF in zig-out turns it into a
+ // source line, and without it a panic message names a KIND of failure with no way to find which
+ // one of them happened. Zero when the caller had no return address to give.
+ soc.rom.print("\r\nMARK PARDES_PANIC_AT 0x%08x\r\n", .{@as(u32, @truncate(first_trace_addr orelse 0))});
+ while (true) {}
+}
+
+/// Reset entry. The bootloader hands over with an unspecified stack pointer and the FPU off, so:
+/// enable the F extension (`mstatus.FS`, which ESP-IDF only ever turns on lazily from a trap handler
+/// this image does not have), establish a stack, clear `.bss`, and call into Zig.
+///
+/// The cache invalidate that this image also needs is the FIRST thing `zig_main` does, not something
+/// done here. Hand-written `la t0, Cache_Invalidate_All` against an absolute linker symbol computed
+/// a PC-relative target and jumped into nowhere (measured: PC=0x88b5d788 with the argument stranded
+/// in a2); Zig generates the addressing for an `extern fn` correctly, and `zig_main` runs before any
+/// `.rodata` is touched anyway.
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, trapEntry
+ \\ csrw mtvec, t0
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/src/esp32p4/input_rescue.zig b/src/esp32p4/input_rescue.zig
new file mode 100644
index 00000000..01dd4820
--- /dev/null
+++ b/src/esp32p4/input_rescue.zig
@@ -0,0 +1,271 @@
+//! Keystrokes rescued from the receive FIFO while the transmitter is busy.
+//!
+//! THE BUG THIS EXISTS FOR. The firmware's loop is read, apply, render, write, and the write blocks
+//! while the transmit FIFO is full - real backpressure, because dropping half an escape sequence
+//! would leave the host terminal in the wrong colour for the rest of the session. But nothing
+//! drained the RECEIVE FIFO during that wait, and the FIFO is 128 bytes (the toolchain package's
+//! `src/hal/uart.zig:52`). A frame of 240 bytes is 21 ms of wire at 115200, and 21 ms of a host
+//! sending at line rate is ~240 bytes, so everything past the 128th was silently gone.
+//!
+//! Measured on the die before the fix, typing a burst in one host write and counting what the editor
+//! actually held: 128 bytes arrived intact, 200 bytes lost 88, 300 bytes lost all 300. From a
+//! keyboard that is a keystroke that never lands, and it looks like a stuck key - the screen is
+//! behind what was typed, and typing more appears to fix it because a later frame repaints the cells
+//! the lost keystrokes would have changed.
+//!
+//! WHY THE POLICY LIVES HERE and not in `uart.zig`: the interesting part is a decision - drain the
+//! receiver while spinning on the transmitter, and what to do when even that overflows - and the
+//! decision is worth testing. `uart.zig` cannot be tested at all without the chip, because every
+//! line of it is an MMIO access. `pump` takes the port as `anytype`, so the same code runs against
+//! the real UART on the board and against a fake with a two-byte FIFO on the host.
+//!
+//! WHY IT IS IN THIS REPOSITORY. It was written in the toolchain repository, next to the UART it
+//! spins on, and it moved here because every number in it is the EDITOR's. 4 KiB is sized against
+//! the ~1.4 KB full repaint `src/esp32p4.zig` emits; "drop the newest, so what survives is a PREFIX of
+//! what was typed" is a statement about documents rather than about UARTs, and it is the editor that
+//! would otherwise appear to invent input. A policy whose every constant comes from one application
+//! belongs beside that application.
+//!
+//! It expects NOTHING from the toolchain package - no module, no register, no target. `std` is the
+//! whole import list, which is what makes the host tests at the bottom possible and what lets the
+//! same source compile for riscv32-freestanding and for the host unchanged.
+//!
+//! ONE COPY, TWO BUILDS. This file is the only copy; the toolchain repository's is gone. Its build
+//! reads this tree across a sibling-relative seam, and names this path twice: once as the
+//! `input_rescue` module of `-Dpardes`'s application (`05-zig-p4/build.zig:230-231`, whose root is
+//! `../02-pardes-code/src/esp32p4/app.zig` by the `-Dapp` default at `:168-169`) and once as the
+//! same-named module of `zig build selftest`'s on-die root (`:437-438`). Both spell
+//! `../02-pardes-code/src/esp32p4/input_rescue.zig`, so there is nothing to keep in step.
+//!
+//! Tested from here, both ways: the host checks at the bottom run as their own `addTest` under
+//! `zig build unit-test` (`02-pardes-code/build.zig:1727-1732` - no `link_libc`, because `std` is
+//! the whole import list), and the same source runs against UART0 on the die under
+//! `zig build esp32p4-test`.
+
+const std = @import("std");
+
+/// Capacity, sized for the worst frame this editor emits.
+///
+/// A full repaint is ~1.4 KB, which is 121 ms of wire at 115200, and 121 ms of a host pasting at
+/// line rate is ~1.4 KB of input. 4 KiB is that with headroom, a power of two so the wrap is a mask
+/// rather than a division, and nothing at all against the board's RAM.
+pub const capacity = 4096;
+
+/// A byte queue that drops the NEWEST byte when full.
+///
+/// Dropping the newest rather than the oldest is deliberate: what survives is then a PREFIX of what
+/// was typed. An editor that loses the end of a paste has done something a person can see and
+/// correct; one that silently reorders keystrokes, or keeps the tail and discards the head, has
+/// corrupted the document in a way that looks like the editor inventing input.
+pub const Ring = struct {
+ buf: [capacity]u8 = undefined,
+ head: usize = 0,
+ len: usize = 0,
+ /// Bytes lost because even this overflowed. Nonzero means input was dropped; it is the honest
+ /// version of the bug rather than a cure for it.
+ dropped: u32 = 0,
+
+ const mask = capacity - 1;
+
+ comptime {
+ std.debug.assert(capacity & mask == 0);
+ }
+
+ pub fn push(r: *Ring, b: u8) void {
+ if (r.len == capacity) {
+ r.dropped +%= 1;
+ return;
+ }
+ r.buf[(r.head + r.len) & mask] = b;
+ r.len += 1;
+ }
+
+ /// Move as much as fits into `out`, oldest first. Returns the count.
+ pub fn pop(r: *Ring, out: []u8) usize {
+ const n = @min(out.len, r.len);
+ for (out[0..n]) |*slot| {
+ slot.* = r.buf[r.head];
+ r.head = (r.head + 1) & mask;
+ }
+ r.len -= n;
+ return n;
+ }
+
+ pub fn clear(r: *Ring) void {
+ r.head = 0;
+ r.len = 0;
+ }
+};
+
+/// Drain everything the port has received into `ring`, without waiting.
+pub fn rescue(port: anytype, ring: *Ring) void {
+ var waiting = port.rxCount();
+ while (waiting > 0) : (waiting -= 1) ring.push(port.popByte());
+}
+
+/// Push `bytes` through `port`, rescuing input whenever the transmitter has no room. Returns the
+/// number of bytes abandoned because the transmitter stopped making progress altogether.
+///
+/// The spin bound is why this returns a count rather than blocking forever: a UART whose core clock
+/// has been gated never makes progress, and on a board with no debugger an infinite spin is
+/// indistinguishable from a crash. A bounded wait turns that into visibly dropped output plus a
+/// counter, which is a diagnosis instead of a mystery.
+pub fn pump(port: anytype, ring: *Ring, bytes: []const u8, spin_limit: u32) u32 {
+ var rest = bytes;
+ while (rest.len > 0) {
+ // One status read per burst, not per byte: reading `txFree` once and pushing that many cuts
+ // the status reads by up to the FIFO depth.
+ var room = port.txFree();
+ var spins: u32 = 0;
+ while (room == 0) {
+ // THE FIX. Every iteration of this wait is time the receiver is filling up, and this is
+ // the only place that can empty it.
+ rescue(port, ring);
+ spins += 1;
+ if (spins > spin_limit) return @intCast(rest.len);
+ room = port.txFree();
+ }
+ const n = @min(room, rest.len);
+ for (rest[0..n]) |b| port.pushByte(b);
+ rest = rest[n..];
+ }
+ return 0;
+}
+
+// ------------------------------------------------------------------------------------ host tests
+
+test "the ring hands bytes back in order" {
+ var r: Ring = .{};
+ for ("hello") |b| r.push(b);
+ var out: [8]u8 = undefined;
+ try std.testing.expectEqual(@as(usize, 5), r.pop(&out));
+ try std.testing.expectEqualStrings("hello", out[0..5]);
+ try std.testing.expectEqual(@as(usize, 0), r.pop(&out));
+}
+
+test "the ring wraps without reordering" {
+ var r: Ring = .{};
+ var out: [capacity]u8 = undefined;
+ // Push and pop most of the buffer so head sits near the end, then straddle the wrap.
+ for (0..capacity - 3) |i| r.push(@intCast(i & 0xff));
+ _ = r.pop(out[0 .. capacity - 3]);
+ for ("straddle") |b| r.push(b);
+ const n = r.pop(&out);
+ try std.testing.expectEqualStrings("straddle", out[0..n]);
+}
+
+test "a full ring drops the newest and says so" {
+ var r: Ring = .{};
+ for (0..capacity) |i| r.push(@intCast(i & 0xff));
+ try std.testing.expectEqual(@as(u32, 0), r.dropped);
+ r.push('!');
+ r.push('!');
+ try std.testing.expectEqual(@as(u32, 2), r.dropped);
+ // The head is intact: what survived is a prefix of what arrived.
+ var out: [4]u8 = undefined;
+ _ = r.pop(&out);
+ try std.testing.expectEqual(@as(u8, 0), out[0]);
+ try std.testing.expectEqual(@as(u8, 1), out[1]);
+}
+
+/// A UART with a small transmit FIFO, a small RECEIVE FIFO, and a host that keeps typing into it.
+///
+/// The receive FIFO is the part that matters and it is modelled the way the hardware behaves: it has
+/// a fixed depth, and a byte that arrives when it is full is *gone*. That is the whole bug.
+///
+/// Time advances on each transmitter status read, which is what `pump` does while it waits. The
+/// transmitter frees a byte only every fourth tick while a typed byte lands on every one: the
+/// transmitter therefore genuinely FILLS, which is the condition the bug needs. A fake whose FIFO
+/// drains as fast as it fills never blocks, so `pump` never waits, so the rescue never runs and the
+/// test proves nothing - the first version of this fake had exactly that flaw.
+const FakePort = struct {
+ tx_cap: u32,
+ tx_used: u32 = 0,
+ sent: std.ArrayList(u8) = .empty,
+ gpa: std.mem.Allocator,
+
+ incoming: []const u8,
+ delivered: usize = 0,
+ rx: [rx_depth]u8 = undefined,
+ rx_head: usize = 0,
+ rx_len: usize = 0,
+ /// Bytes the wire delivered into a full receive FIFO. The hardware has no counter for this,
+ /// which is exactly why the bug was invisible.
+ lost: u32 = 0,
+
+ ticks: u32 = 0,
+
+ const rx_depth = 8;
+ const tx_drain_every = 4;
+
+ fn tick(p: *FakePort) void {
+ p.ticks += 1;
+ if (p.ticks % tx_drain_every == 0 and p.tx_used > 0) p.tx_used -= 1;
+ if (p.delivered < p.incoming.len) {
+ const b = p.incoming[p.delivered];
+ p.delivered += 1;
+ if (p.rx_len == rx_depth) {
+ p.lost += 1;
+ } else {
+ p.rx[(p.rx_head + p.rx_len) % rx_depth] = b;
+ p.rx_len += 1;
+ }
+ }
+ }
+
+ fn txFree(p: *FakePort) u32 {
+ p.tick();
+ return p.tx_cap - p.tx_used;
+ }
+
+ fn pushByte(p: *FakePort, b: u8) void {
+ p.sent.append(p.gpa, b) catch unreachable;
+ p.tx_used += 1;
+ }
+
+ fn rxCount(p: *FakePort) u32 {
+ return @intCast(p.rx_len);
+ }
+
+ fn popByte(p: *FakePort) u8 {
+ const b = p.rx[p.rx_head];
+ p.rx_head = (p.rx_head + 1) % rx_depth;
+ p.rx_len -= 1;
+ return b;
+ }
+};
+
+test "a long transmit does not lose the input that arrives during it" {
+ // THE REGRESSION. Delete the `rescue` call inside `pump`'s wait and this fails: the receive FIFO
+ // is eight bytes deep, the typing below is far longer than that, and every byte that arrives
+ // into a full FIFO is gone with nothing to record it. That is the die's 88-of-200 in miniature.
+ const typed = "the quick brown fox jumps over the lazy dog, twice over, and then some more";
+ var port: FakePort = .{ .tx_cap = 2, .incoming = typed, .gpa = std.testing.allocator };
+ defer port.sent.deinit(std.testing.allocator);
+ var ring: Ring = .{};
+
+ const frame = "\x1b[1;1H" ++ "x" ** 400;
+ try std.testing.expectEqual(@as(u32, 0), pump(&port, &ring, frame, 1_000_000));
+
+ // Every output byte went out, in order.
+ try std.testing.expectEqualStrings(frame, port.sent.items);
+ // Nothing the wire delivered was dropped, by the FIFO or by the ring.
+ try std.testing.expectEqual(@as(u32, 0), port.lost);
+ try std.testing.expectEqual(@as(u32, 0), ring.dropped);
+ // And what was rescued, plus whatever is still sitting in the FIFO, is exactly what was typed -
+ // in order, which is the other half of the contract.
+ var got: [capacity]u8 = undefined;
+ var n = ring.pop(&got);
+ while (port.rxCount() > 0) : (n += 1) got[n] = port.popByte();
+ try std.testing.expectEqualStrings(typed[0..port.delivered], got[0..n]);
+ try std.testing.expect(port.delivered == typed.len);
+}
+
+test "a transmitter that never drains gives up and reports what it abandoned" {
+ var port: FakePort = .{ .tx_cap = 0, .incoming = "", .gpa = std.testing.allocator };
+ defer port.sent.deinit(std.testing.allocator);
+ var ring: Ring = .{};
+ // tx_cap 0 means txFree is always 0, so no byte can ever go out.
+ try std.testing.expectEqual(@as(u32, 5), pump(&port, &ring, "abcde", 32));
+ try std.testing.expectEqual(@as(usize, 0), port.sent.items.len);
+}
diff --git a/src/esp32p4/selftest.zig b/src/esp32p4/selftest.zig
new file mode 100644
index 00000000..0d692d98
--- /dev/null
+++ b/src/esp32p4/selftest.zig
@@ -0,0 +1,351 @@
+//! pardes's own test suite for the ESP32-P4, which runs ON THE DIE.
+//!
+//! WHY THIS EXISTS. Every serious bug this port has produced was invisible to a host test, and two
+//! of them were invisible for months. `std.mem.eql` compares a byte at a time on this target and
+//! several times faster on the host, so the firmware's largest read was three times slower than it
+//! needed to be and nothing on a laptop could tell. A lone ESC resolves to the Escape key, which is
+//! right when a kernel hands over a whole escape sequence and wrong when a 115200 line hands over
+//! one byte every 87 microseconds. A full transmit FIFO stopped anything draining the receiver, and
+//! the FIFO depth is a hardware number. None of those is a logic error you can reason your way to
+//! from a host: they are properties of THIS chip, THIS clock and THIS wire.
+//!
+//! So the checks below are chosen by one rule: a check belongs here only if the die can answer it
+//! and a host cannot. Anything that is pure logic - the ring's wrap-around, the mouse coalescer's
+//! ordering - already has a deterministic host test in `zig build unit-test`, which is faster, needs
+//! no hardware, and is where such a thing belongs. Duplicating those here would make this suite
+//! longer and no stronger.
+//!
+//! ## Why the suite is in the editor's repository
+//!
+//! It was written in the `05-zig-p4` toolchain repository as `examples/selftest.zig`, and it was
+//! never an example of anything. Re-read the paragraph above: every claim it makes is a claim about
+//! THIS program on this board. The word-at-a-time `std.mem.eql` it measures against is the reason
+//! the editor's frame diff compares rows a `u32` at a time; the lone-ESC decode and the
+//! transmit-FIFO backpressure are `input_rescue.zig`, a sibling file in this directory; the heap
+//! span and the allocator churn are what decide how large a grid this board can drive; and the
+//! clock check is the divisor under every `-Dprof` cycle count the editor reports. A suite that
+//! specific to one application belongs beside it, so that changing a source and changing the check
+//! which defends it are one diff in one repository rather than two commits in two trees.
+//!
+//! What stayed behind in the toolchain is the half that is about the CHIP: the image builder's rules,
+//! the register layer's field arithmetic, the console bridge's escape matcher. Those still run under
+//! `zig build test` over there and need no board.
+//!
+//! ## What it expects from the toolchain package
+//!
+//! Four of its modules and no more: `soc` (mask-ROM printf, the cycle counter), `hal` (UART0, the
+//! systimer, clock/reset), `config` (this build's `cpu_mhz`) and `heap` (the coalescing allocator).
+//! They arrive as the `zig_p4` dependency, which also supplies what makes this an image at all -
+//! the generated linker script, `ENTRY(_start)` and the app descriptor via `firmware(...).attach`,
+//! then `ImageStep`, `FlashStep` and `SelftestStep`.
+//!
+//! `input_rescue` is the fifth import and is NOT that package's: it is the sibling file in this
+//! directory, handed over as a named MODULE rather than imported as a path. Deliberately so - the
+//! `pub` on `FakePort`'s methods below is what lets that module reach them by duck typing across the
+//! boundary, and both builds, this repository's `esp32p4-test` and the toolchain's `selftest`, wire
+//! the identical root the identical way. One file, one arrangement, nothing to drift.
+//!
+//! Not a `zig test` binary, deliberately. Zig's test runner wants an OS, and `std.testing.allocator`
+//! is a debug allocator over the page allocator, which on freestanding is either a compile error or
+//! a lie. A hand-rolled harness is thirty lines and answers to nobody.
+//!
+//! Run with: zig build esp32p4-test -Dplatform=esp32p4 -Desp32p4-firmware (from here)
+//! or: zig build selftest (from ../05-zig-p4)
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const config = @import("config");
+const heapmod = @import("heap");
+const input_rescue = @import("input_rescue");
+
+/// Reset entry. Identical in shape to `src/main.zig`'s and for the same reasons: the bootloader hands
+/// over with an unspecified stack pointer and the FPU off, so set `mstatus.FS`, establish a stack,
+/// clear `.bss`, and jump.
+///
+/// Leaving this out is what made the first draft of this file unbuildable, and the failure said
+/// nothing useful: `-fentry=_start` found no such symbol, `--gc-sections` then discarded every
+/// function as unreachable, and the image builder reported `NotTwoMappedSegments` because
+/// `.flash.text` had nothing left in it. `zig build layout` now prints `entry 0x0` for exactly that,
+/// which is the same diagnosis in one line.
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, first_trace_addr: ?usize) noreturn {
+ // `msg` is a slice and carries no terminator - std's own panics are formatted into a buffer -
+ // so it goes out with a length rather than through a `%s` that would read past the end of it.
+ soc.rom.print("\r\nMARK SELFTEST_PANIC at 0x%08x: ", .{@as(u32, @truncate(first_trace_addr orelse 0))});
+ hal.uart.Uart.init(0).write(msg);
+ // A panic is a FAILED RUN, and the host is watching for the summary line. Without this the
+ // run looks like a board that never answered, which is a different diagnosis entirely.
+ soc.rom.print("\r\nMARK SELFTEST DONE pass=%u fail=%u\r\n", .{ passed, failed + 1 });
+ while (true) {}
+ }
+}.call);
+
+var passed: u32 = 0;
+var failed: u32 = 0;
+
+/// One claim about the silicon. Printed either way: a suite that only speaks up when it fails gives
+/// no way to tell "all good" from "never ran", and on a board that difference matters.
+fn check(name: [*:0]const u8, ok: bool) void {
+ if (ok) {
+ passed += 1;
+ soc.rom.print("MARK SELFTEST ok %s\r\n", .{name});
+ } else {
+ failed += 1;
+ soc.rom.print("MARK SELFTEST FAIL %s\r\n", .{name});
+ }
+}
+
+/// Word-at-a-time equality, the same shape the editor's frame diff uses.
+fn sameBytesWordwise(a: []const u8, b: []const u8) bool {
+ if (a.len != b.len) return false;
+ if ((@intFromPtr(a.ptr) | @intFromPtr(b.ptr)) & 3 == 0) {
+ const n = a.len / 4;
+ const wa: [*]align(4) const u32 = @ptrCast(@alignCast(a.ptr));
+ const wb: [*]align(4) const u32 = @ptrCast(@alignCast(b.ptr));
+ for (wa[0..n], wb[0..n]) |x, y| {
+ if (x != y) return false;
+ }
+ return std.mem.eql(u8, a[n * 4 ..], b[n * 4 ..]);
+ }
+ return std.mem.eql(u8, a, b);
+}
+
+/// A UART with a receive FIFO that loses whatever arrives into a full one, as the hardware does.
+/// `pub` on the methods because `input_rescue` is a separate module here and reaches them by duck
+/// typing across it.
+const FakePort = struct {
+ tx_cap: u32,
+ tx_used: u32 = 0,
+ ticks: u32 = 0,
+ sent: u32 = 0,
+ incoming: []const u8,
+ delivered: usize = 0,
+ rx: [8]u8 = undefined,
+ rx_head: usize = 0,
+ rx_len: usize = 0,
+ lost: u32 = 0,
+
+ fn tick(p: *FakePort) void {
+ p.ticks += 1;
+ if (p.ticks % 4 == 0 and p.tx_used > 0) p.tx_used -= 1;
+ if (p.delivered < p.incoming.len) {
+ const byte = p.incoming[p.delivered];
+ p.delivered += 1;
+ if (p.rx_len == p.rx.len) {
+ p.lost += 1;
+ } else {
+ p.rx[(p.rx_head + p.rx_len) % p.rx.len] = byte;
+ p.rx_len += 1;
+ }
+ }
+ }
+ pub fn txFree(p: *FakePort) u32 {
+ p.tick();
+ return p.tx_cap - p.tx_used;
+ }
+ pub fn pushByte(p: *FakePort, _: u8) void {
+ p.sent += 1;
+ p.tx_used += 1;
+ }
+ pub fn rxCount(p: *FakePort) u32 {
+ return @intCast(p.rx_len);
+ }
+ pub fn popByte(p: *FakePort) u8 {
+ const byte = p.rx[p.rx_head];
+ p.rx_head = (p.rx_head + 1) % p.rx.len;
+ p.rx_len -= 1;
+ return byte;
+ }
+};
+
+/// Backing store for the heap checks. Static, because the point is to exercise the allocator on real
+/// L2MEM rather than to find out where a stack array happens to land.
+var heap_area: [64 * 1024]u8 align(16) = undefined;
+
+export fn zig_main() noreturn {
+ // THE CLOCK RAISE, performed here rather than assumed, which is what turns the frequency check
+ // at the end into a test of `setCpuFreq` instead of a tautology. The first version of this file
+ // read `config.cpu_mhz` and compared the die against it without ever setting it, so
+ // `-Dcpu-mhz=360` failed with `khz=90001 want=360000` - the check was right and the expectation
+ // was wrong. Same call, and the same order, as `src/esp32p4/app.zig`.
+ if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) {
+ 360 => .mhz360,
+ else => .mhz90,
+ });
+ hal.systimer.init();
+
+ soc.rom.print("\r\nMARK SELFTEST_START cpu_mhz=%u\r\n", .{@as(u32, config.cpu_mhz)});
+
+ // ------------------------------------------------ 1. the memory model the memory words assume
+ //
+ // `Peek`, `Poke` and `Hexdump` reach the bus through `*allowzero volatile` pointers and refuse an
+ // unaligned word. Both halves are claims about this core, and neither is checkable on a host.
+ {
+ const cell: *volatile u32 = @ptrCast(@alignCast(&heap_area[0]));
+ cell.* = 0xdeadbeef;
+ check("an aligned word round-trips through a volatile pointer", cell.* == 0xdeadbeef);
+
+ // Every byte offset in a word, readable and writable: this is what `Hexdump` does, and it is
+ // why `Hexdump` needs no alignment while `Peek` does.
+ var all_offsets_ok = true;
+ for (0..4) |i| {
+ const at: *volatile u8 = @ptrCast(&heap_area[16 + i]);
+ at.* = @intCast(0xa0 + i);
+ if (at.* != 0xa0 + @as(u8, @intCast(i))) all_offsets_ok = false;
+ }
+ check("a byte at every offset in a word round-trips", all_offsets_ok);
+
+ // THE VOLATILE PROMISE. Two reads of a running counter must be two reads. Were the optimiser
+ // allowed to fold them, `Peek` would print one value twice for a register that had changed,
+ // which is the one thing a memory word must never do.
+ const first = hal.systimer.micros(.unit0) orelse 0;
+ var spin: u32 = 0;
+ while (spin < 4000) : (spin += 1) asm volatile ("" ::: .{ .memory = true });
+ const second = hal.systimer.micros(.unit0) orelse 0;
+ check("two reads of a live counter are two reads", second != first);
+ check("and that counter runs forwards", second > first);
+ }
+
+ // --------------------------------------- 2. word-wise equality, on THIS instruction set
+ //
+ // The editor's frame diff compares rows a `u32` at a time because `std.mem.eql` compares a byte
+ // at a time here: 223 us against 66 for the same answer. "The same answer" is the part that has
+ // to hold on the target rather than on the host, so it is checked against `std.mem.eql` itself,
+ // at every difference position, at both alignments, and at lengths that are not multiples of 4.
+ {
+ var a: [64]u8 align(4) = undefined;
+ var b: [64]u8 align(4) = undefined;
+ for (&a, 0..) |*slot, i| slot.* = @intCast(i);
+ @memcpy(&b, &a);
+
+ var agree = true;
+ for (0..a.len) |len| {
+ if (sameBytesWordwise(a[0..len], b[0..len]) != std.mem.eql(u8, a[0..len], b[0..len])) agree = false;
+ }
+ check("aligned equality agrees with std.mem.eql at every length", agree);
+
+ agree = true;
+ for (0..a.len) |i| {
+ b[i] ^= 0xff;
+ if (sameBytesWordwise(&a, &b) != std.mem.eql(u8, &a, &b)) agree = false;
+ if (sameBytesWordwise(a[0..33], b[0..33]) != std.mem.eql(u8, a[0..33], b[0..33])) agree = false;
+ b[i] ^= 0xff;
+ }
+ check("a difference at any position is found, as std.mem.eql finds it", agree);
+
+ agree = true;
+ // UNALIGNED, which is why `sameBytes` tests alignment at RUNTIME: `Cell` is all u8 fields, so
+ // whether a row starts on a word boundary belongs to the allocator and not to the type.
+ for (1..4) |off| {
+ const ua = a[off..];
+ const ub = b[off..];
+ if (sameBytesWordwise(ua, ub) != std.mem.eql(u8, ua, ub)) agree = false;
+ b[off + 5] ^= 0xff;
+ if (sameBytesWordwise(ua, ub) != std.mem.eql(u8, ua, ub)) agree = false;
+ b[off + 5] ^= 0xff;
+ }
+ check("unaligned spans fall back and still agree", agree);
+ }
+
+ // ------------------------------------------------- 3. the rescue, on the real codegen
+ //
+ // The logic has a host test. What that cannot say is whether it behaves the same compiled for
+ // this core at this optimisation level, which is a question only a board answers.
+ {
+ const typed = "the quick brown fox jumps over the lazy dog, and then some more besides";
+ var port: FakePort = .{ .tx_cap = 2, .incoming = typed };
+ var ring: input_rescue.Ring = .{};
+ const frame = "\x1b[1;1H" ++ "x" ** 300;
+ const abandoned = input_rescue.pump(&port, &ring, frame, 1_000_000);
+
+ check("the frame went out whole", abandoned == 0 and port.sent == frame.len);
+ check("the receive FIFO never overflowed", port.lost == 0);
+ check("the ring dropped nothing", ring.dropped == 0);
+
+ var got: [128]u8 = undefined;
+ var n = ring.pop(&got);
+ while (port.rxCount() > 0 and n < got.len) : (n += 1) got[n] = port.popByte();
+ check("every rescued byte, in order", n == typed.len and std.mem.eql(u8, got[0..n], typed));
+ }
+
+ // --------------------------------------------------- 4. the allocator on real L2MEM
+ //
+ // The editor's whole geometry ceiling is an allocator question, and this is the allocator, on the
+ // memory it actually runs in rather than on a host's malloc.
+ {
+ var h = heapmod.Heap.init(heap_area[0..]);
+ const gpa = h.allocator();
+
+ const one = gpa.alloc(u32, 256) catch null;
+ check("a modest allocation succeeds", one != null);
+ if (one) |slice| {
+ check("and is aligned for its element", @intFromPtr(slice.ptr) % @alignOf(u32) == 0);
+ for (slice, 0..) |*slot, i| slot.* = @intCast(i * 7);
+ var intact = true;
+ for (slice, 0..) |slot, i| {
+ if (slot != i * 7) intact = false;
+ }
+ check("and holds what was written to it", intact);
+ gpa.free(slice);
+ }
+
+ // FREE THEN REUSE. A heap that cannot hand the same bytes back is a heap that runs out, which
+ // on this board is the difference between a 40x12 grid and an 80x24 one.
+ var churn_ok = true;
+ for (0..64) |_| {
+ const block = gpa.alloc(u8, 1024) catch {
+ churn_ok = false;
+ break;
+ };
+ gpa.free(block);
+ }
+ check("a kilobyte can be taken and returned repeatedly", churn_ok);
+
+ // AND IT REFUSES CLEANLY. An allocator that returns garbage instead of an error when it is
+ // out is the failure mode that cost an afternoon during bring-up.
+ const absurd = gpa.alloc(u8, heap_area.len * 4);
+ check("an impossible allocation returns an error", absurd == error.OutOfMemory);
+ }
+
+ // ------------------------------------------ 5. the clock, which everything divides by
+ //
+ // Every cycle count this firmware reports is divided by the configured frequency somewhere, and
+ // the systimer is clocked from the crystal rather than from the CPU - which is exactly what makes
+ // it a reference the CPU cannot flatter. The 90-to-360 MHz raise rested on this comparison.
+ {
+ const t0 = hal.systimer.micros(.unit0) orelse 0;
+ const c0 = soc.cycles();
+ while ((hal.systimer.micros(.unit0) orelse 0) -% t0 < 20_000) {}
+ const us = (hal.systimer.micros(.unit0) orelse 0) -% t0;
+ const cy = soc.cycles() - c0;
+ const khz: u32 = if (us > 0) @intCast(cy * 1000 / us) else 0;
+ const want: u32 = @as(u32, config.cpu_mhz) * 1000;
+ // Two percent: far wider than either clock's error, far narrower than the 4x a wrong divider
+ // would produce.
+ const slack = want / 50;
+ soc.rom.print("MARK SELFTEST_CLOCK khz=%u want=%u\r\n", .{ khz, want });
+ check("the cycle counter and the systimer agree on the CPU frequency", khz > want - slack and khz < want + slack);
+ }
+
+ soc.rom.print("MARK SELFTEST DONE pass=%u fail=%u\r\n", .{ passed, failed });
+ while (true) {}
+}
diff --git a/src/esp32p4/uart.zig b/src/esp32p4/uart.zig
new file mode 100644
index 00000000..0afd145b
--- /dev/null
+++ b/src/esp32p4/uart.zig
@@ -0,0 +1,165 @@
+//! UART0 as the editor's terminal: bytes out, bytes in, and nothing else.
+//!
+//! This is the whole of the firmware's I/O. There is no framebuffer and no keyboard; the board
+//! emits ANSI and consumes ANSI, and the terminal emulator on the far end of the CH340 does the
+//! rest of the work - including answering the editor's own capability queries, which travel down
+//! this wire like any other bytes.
+//!
+//! WHY IT IS IN THIS REPOSITORY. It is not a UART driver - that is `hal.uart`, which stays in the
+//! toolchain package and is checked against ESP-IDF's own headers by `zig build diff` there. This is
+//! the EDITOR's use of one: which instance the console is, that the transmitter is real
+//! backpressure because a truncated escape sequence corrupts the host terminal, that rescued
+//! keystrokes must come out before FIFO ones, and that the first thing to do at startup is discard
+//! the host bridge's synthetic resize report. Every one of those is a statement about the editor, so
+//! the file moved to sit beside it. See `app.zig`'s header for the boundary in full.
+//!
+//! What it expects from the toolchain package is exactly one module: `hal`, for `hal.uart.Uart`.
+//! Nothing else here reaches the chip. The sibling `input_rescue.zig` is a plain file, not a module,
+//! because it is this repository's own policy.
+//!
+//! Deliberately not a `std.Io.Writer`. The ANSI encoding lives on the other side of the C ABI, next
+//! to the vaxis that produces it (see `app.zig` for why the seam is there and not elsewhere), so
+//! what crosses into this file is already a finished run of bytes. A writer here would be a second
+//! buffer in front of one that already exists.
+//!
+//! Two decisions worth stating, because both are measurements rather than preferences.
+//!
+//! **Batched FIFO access.** The naive push is `while (txFree() == 0) {}` then `pushByte`, once per
+//! byte: one MMIO read per byte at best, many while the FIFO is full. Reading `txFree` once and
+//! then pushing that many cuts the status reads by up to the FIFO depth (128, the toolchain
+//! package's `src/hal/uart.zig:52`). At 115200 the wire costs ~86 us per byte and dwarfs either
+//! version, so today this is merely free - and it stops being free the moment the divider is raised.
+//!
+//! **UART0's configuration is never touched.** Not the divider, not the format, not the pad
+//! routing, and above all not `reset()`. The second-stage bootloader configured this block, and
+//! `src/hal/uart.zig:195-211` records what happens if it is reset: UART_CLKDIV returns to its
+//! power-on value, the console turns to garbage mid-sentence, and the board takes a watchdog reset
+//! with nothing readable left to explain it. Everything here touches FIFO offset 0x000 and the
+//! status register, and nothing else.
+
+const hal = @import("hal");
+const input_rescue = @import("input_rescue.zig");
+
+/// UART0: the instance the CH340 is wired to, and the one the ROM and bootloader configured.
+const uart0 = hal.uart.Uart.init(0);
+
+/// Keystrokes taken off the receiver while the transmitter was full. See `input_rescue`: without
+/// this, anything typed into a frame longer than the 128-byte FIFO was silently gone.
+var rescued: input_rescue.Ring = .{};
+
+/// Push `bytes` into the TX FIFO, blocking while it is full.
+///
+/// The spin is normally bounded by the wire - a full 128-byte FIFO drains in 11 ms at 115200 - and
+/// dropping instead of waiting would truncate an escape sequence, leaving the host terminal in the
+/// wrong colour for the rest of the session. So the wait is real backpressure.
+///
+/// But it is BOUNDED, for the reason the toolchain package's `src/hal/uart.zig:182-186` gives about
+/// `update()`: a UART whose core clock has been gated never makes progress, and "on a board with no
+/// debugger an infinite spin is indistinguishable from a crash". That is not hypothetical here - it
+/// is how this port spent an afternoon: output stopped mid-boot with no panic and no watchdog (the
+/// RTC watchdog having been correctly disabled), which looked like a hang in whatever code came next
+/// rather than a stalled transmitter. A bounded wait turns that into visibly dropped output plus a
+/// counter, which is a diagnosis instead of a mystery.
+///
+/// The limit is per burst, not per call, and generous: 1,000,000 status reads is far longer than
+/// any legitimate drain and still a fraction of a second.
+pub fn write(bytes: []const u8) void {
+ dropped +%= input_rescue.pump(uart0, &rescued, bytes, 1_000_000);
+}
+
+/// Bytes abandoned because the transmitter stopped making progress. Nonzero means the console is
+/// lying about what happened, so it is worth printing.
+pub var dropped: u32 = 0;
+
+/// One byte, for callers that must not touch `.rodata` to say anything - which during bring-up is
+/// the difference between a diagnostic and a second copy of the bug being diagnosed.
+pub fn writeByte(b: u8) void {
+ var spins: u32 = 0;
+ while (uart0.txFree() == 0) {
+ spins += 1;
+ if (spins > 1_000_000) {
+ dropped +%= 1;
+ return;
+ }
+ }
+ uart0.pushByte(b);
+}
+
+/// Emit `n` bytes read from `addr` as two hex digits each, computing the digits arithmetically so
+/// nothing here reads a lookup table. Used to answer "does a load from this address return what the
+/// linker put there", which is not a question a string literal can be trusted to ask.
+pub fn dumpHex(addr: u32, n: u32) void {
+ const p: [*]const volatile u8 = @ptrFromInt(addr);
+ var i: u32 = 0;
+ while (i < n) : (i += 1) {
+ const byte = p[i];
+ for ([2]u8{ byte >> 4, byte & 0xf }) |nib| {
+ writeByte(if (nib < 10) '0' + nib else 'a' + (nib - 10));
+ }
+ }
+ writeByte('\r');
+ writeByte('\n');
+}
+
+/// A u32 as eight hex digits, reading no memory at all.
+pub fn dumpWord(v: u32) void {
+ var shift: u5 = 28;
+ while (true) {
+ const nib: u8 = @intCast((v >> shift) & 0xf);
+ writeByte(if (nib < 10) '0' + nib else 'a' + (nib - 10));
+ if (shift == 0) break;
+ shift -= 4;
+ }
+ writeByte('\r');
+ writeByte('\n');
+}
+
+/// Move whatever the host has sent into `buf`, without waiting. Returns the count.
+///
+/// Non-blocking on purpose: the loop has a frame to render and a core to pump, and the editor must
+/// not stall on a keystroke that may never come. `rxCount` is read once per call and the FIFO
+/// drained to that mark, so a fast typist or a pasted buffer cannot hold the loop here.
+pub fn read(buf: []u8) usize {
+ // RESCUED BYTES FIRST. They arrived before anything still sitting in the FIFO, and an editor
+ // that reorders keystrokes is worse than one that drops them.
+ var n = rescued.pop(buf);
+ const waiting = @min(uart0.rxCount(), buf.len - n);
+ for (buf[n..][0..waiting]) |*slot| slot.* = uart0.popByte();
+ n += waiting;
+ return n;
+}
+
+/// Take whatever has arrived off the receiver right now, without waiting and without handing it to
+/// anyone. For callers that are about to spend a while not reading: `write` does this while the
+/// transmitter is full, and the loop does it between chunks of input, because applying a keystroke
+/// gets more expensive as the line grows and 128 bytes of FIFO is only 11 ms at 115200.
+pub fn rescueNow() void {
+ input_rescue.rescue(uart0, &rescued);
+}
+
+/// Input abandoned because even the rescue buffer overflowed. Distinct from `dropped`, which is
+/// OUTPUT abandoned by a stalled transmitter.
+pub fn inputDropped() u32 {
+ return rescued.dropped;
+}
+
+/// Discard anything already received, returning how much. Used once at startup: the host-side
+/// bridge injects a window-size report before this program exists, and the bootloader's chatter has
+/// already been echoed at the host. Neither is user input.
+///
+/// Pops rather than calling `resetRxFifo`, which is a CONF0_SYNC read-modify-write plus two commits
+/// on the console UART - see this file's header.
+pub fn drainInput() u32 {
+ var discarded: u32 = 0;
+ while (uart0.rxCount() > 0) : (discarded += 1) _ = uart0.popByte();
+ discarded += @intCast(rescued.len);
+ rescued.clear();
+ return discarded;
+}
+
+/// The rate the hardware is actually producing, by reading its dividers back. Reported rather than
+/// assumed: the host has to be opened at the same rate, and a mismatch shows up as garbage on the
+/// screen rather than as an error anyone can act on.
+pub fn baudrate() u32 {
+ return uart0.baudrate(uart0.clockSource().nominalHz());
+}
diff --git a/src/file_pane.zig b/src/file_pane.zig
index e0150de2..5c7f4b77 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -16,14 +16,9 @@ const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
const term_pane = @import("term_pane.zig");
const dump = @import("dump.zig");
+const limits = @import("limits.zig");
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
-/// 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.
-const undo_max = if (@import("pardes_config").platform == .p4) 16 else 256;
/// Content and primary selection at one file edit boundary. Keeping only the
/// primary avoids putting pardes.MAX_SELS ranges in every history entry.
@@ -54,9 +49,9 @@ pub const State = struct {
highlights: []u8 = &.{},
highlight_start: usize = 0,
syntax_dirty: bool = true,
- undo: [undo_max]Snapshot = undefined,
+ undo: [limits.undo_max]Snapshot = undefined,
undo_len: usize = 0,
- redo: [undo_max]Snapshot = undefined,
+ redo: [limits.undo_max]Snapshot = undefined,
redo_len: usize = 0,
};
@@ -220,6 +215,92 @@ test "display columns map complete Unicode graphemes" {
try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀"));
}
+test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" {
+ // The arm claims a one-byte printable ASCII grapheme is one cell without asking `gwidth`. That
+ // is only worth having if the two never disagree, so ask both for every byte the arm can see -
+ // including \t, \r, the rest of the C0 controls and DEL, which the range test excludes and
+ // which must therefore still come back from `gwidth` (or, for the tab, from the config).
+ const ref = struct {
+ fn width(grapheme: []const u8) usize {
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
+ }
+ }.width;
+
+ var one: [1]u8 = undefined;
+ var b: u8 = 0;
+ while (b < 0x80) : (b += 1) {
+ one[0] = b;
+ try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1]));
+ }
+ // Multi-byte clusters never reach the arm (len != 1), so they pin that it does not widen its
+ // claim: a combining sequence and a ZWJ emoji are one and two cells, a CJK glyph is two, and
+ // an invalid byte is the one cell `gwidth` reports for U+FFFD-shaped input.
+ for ([_][]const u8{
+ "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}",
+ "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8",
+ }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g));
+}
+
+test "the ASCII run in fitEnd survives an exhaustive byte sweep" {
+ // The case list in the test above is hand-picked; this one is not. Every byte 0x00..0x7f is
+ // placed next to every neighbour that can change the answer - a combining mark, a ZWJ
+ // sequence, a spacing mark, a variation selector, a wide glyph, and a bad start byte, a
+ // truncated tail and a bad continuation - and every break column is compared against the
+ // grapheme walk. An off-by-one column here moves text between wrapped rows, so equality is
+ // exact, not approximate.
+ const reference = struct {
+ fn fitEnd(text: []const u8, start: usize, width: usize) usize {
+ var end = start;
+ var used: usize = 0;
+ while (end < text.len) {
+ const next_end = modal.nextGrapheme(text, end);
+ const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
+ if (next_used > width) return if (end == start) next_end else end;
+ used = next_used;
+ end = next_end;
+ }
+ return end;
+ }
+ }.fitEnd;
+
+ const neighbours = [_][]const u8{
+ "", "z", "\u{301}", "\u{200d}\u{1f680}",
+ "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}",
+ "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8",
+ };
+ var buf: [16]u8 = undefined;
+ // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary
+ // as well as before it and inside the multi-byte cluster that follows it.
+ var prefixed: [18]u8 = undefined;
+ prefixed[0] = 'a';
+ prefixed[1] = 'b';
+ var b: u8 = 0;
+ while (b < 0x80) : (b += 1) {
+ buf[0] = b;
+ for (neighbours) |tail| {
+ @memcpy(buf[1..][0..tail.len], tail);
+ const pair = buf[0 .. 1 + tail.len];
+ @memcpy(prefixed[2..][0..pair.len], pair);
+ for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| {
+ var width: usize = 0;
+ while (width <= text.len + 3) : (width += 1) {
+ var start: usize = 0;
+ while (start <= text.len) : (start += 1) {
+ std.testing.expectEqual(
+ reference(text, start, width),
+ fitEnd(text, start, width),
+ ) catch |e| {
+ std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width });
+ return e;
+ };
+ }
+ }
+ }
+ }
+ }
+}
+
fn fitEnd(text: []const u8, start: usize, width: usize) usize {
var end = start;
var used: usize = 0;
diff --git a/src/limits.zig b/src/limits.zig
new file mode 100644
index 00000000..965f4d0a
--- /dev/null
+++ b/src/limits.zig
@@ -0,0 +1,212 @@
+//! 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;
+
+/// 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;
+
+/// 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);
+}
diff --git a/src/look.zig b/src/look.zig
index df1a15ae..90945013 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -566,7 +566,7 @@ const platform_has_fs = !pardes.isolated and switch (pardes.platform) {
// The browser's filesystem is the embedded source archive; the P4
// firmware's is whatever the serial host answers for, through the Host
// vtable — never a path this process opens.
- .web, .p4 => false,
+ .web, .esp32p4 => false,
};
// Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the
diff --git a/src/main.zig b/src/main.zig
index c60fb888..08beae86 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -85,6 +85,19 @@ const help_text =
\\ PARDES_FS and PARDES_PANE into every pane shell
\\ --fs=<dir> ...at <dir> instead. Must be absolute; pardes
\\ unmounts it on exit but leaves the directory
+ \\ --detach run this session with NO terminal of its own,
+ \\ serving frontends over a unix socket beside the
+ \\ nested-instance one. The core, the panes and the
+ \\ undo history outlive every frontend that attaches
+ \\ --detach=<name> ...named <name> rather than this process's pid, so
+ \\ a frontend can say which session it wants. One
+ \\ path component: no '/' and nothing empty
+ \\ --attach become a frontend of the one detached session
+ \\ that is running: draw its screen, send it input,
+ \\ fork its pane shells. Several frontends may be
+ \\ attached at once and all see the same screen
+ \\ --attach=<name> ...of the session called <name>, which is what to
+ \\ use when more than one is running
\\ -h, --help show this help and exit
\\
;
@@ -128,14 +141,27 @@ fn nativeMain(init: std.process.Init) !void {
// bare `pardes` boots straight into tty mode — and so does a `pardes`
// carrying nothing but flags that say something about the SESSION rather
// than about its layout: --nested is about this session's relationship to
- // its parent, --fs is about who may script it, and neither says anything
- // about what should be on screen. Anything else (a FILE, -n, --tty) is
- // layout, and answers this question itself further down.
+ // its parent, --fs is about who may script it, --detach is about who may
+ // WATCH it, --attach is about whose screen this one is showing, and none of
+ // the four says anything about what should be on screen. Anything else (a
+ // FILE, -n, --tty) is layout, and answers this question itself further
+ // down.
opts.tty_only = for (args[1..]) |a| {
if (!std.mem.eql(u8, a, "--nested") and
!std.mem.eql(u8, a, "--fs") and
- !std.mem.startsWith(u8, a, "--fs=")) break false;
+ !std.mem.startsWith(u8, a, "--fs=") and
+ !std.mem.eql(u8, a, "--detach") and
+ !std.mem.startsWith(u8, a, "--detach=") and
+ !std.mem.eql(u8, a, "--attach") and
+ !std.mem.startsWith(u8, a, "--attach=")) break false;
} else true;
+ // `--detach[=<name>]`: null when it was not given, so the empty string is
+ // free to mean "the default name" the way opts.fs uses it for a directory.
+ var detach: ?[]const u8 = null;
+ // ...and `--attach[=<name>]`, the same shape: null when it was not given,
+ // and the empty string means "the one session there is" (tty.zig
+ // `sessionName`) rather than a session with no name.
+ var attach: ?[]const u8 = null;
// Kept RAW until every flag is parsed: classifying it means chdir'ing into
// a directory and recording nothing, and the nested client below still
// needs the word itself to resolve.
@@ -171,6 +197,22 @@ fn nativeMain(init: std.process.Init) !void {
opts.fs = a["--fs=".len..];
} else if (std.mem.eql(u8, a, "--nested")) {
opts.nested = true;
+ } else if (std.mem.eql(u8, a, "--detach")) {
+ detach = "";
+ } else if (std.mem.startsWith(u8, a, "--detach=")) {
+ // `--detach=<name>` and never `--detach <name>`, for exactly the
+ // reason --fs gives above: the flag is useful bare, so a two-word
+ // form would make `pardes --detach README` a session called README
+ // that opens no file.
+ detach = a["--detach=".len..];
+ } else if (std.mem.eql(u8, a, "--attach")) {
+ attach = "";
+ } else if (std.mem.startsWith(u8, a, "--attach=")) {
+ // `--attach=<name>` and never `--attach <name>`, for the reason
+ // --fs states above and --detach repeats: the flag is useful bare,
+ // so a two-word form would make `pardes --attach README` an attach
+ // to a session called README that opens no file.
+ attach = a["--attach=".len..];
} else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
try std.Io.File.stdout().writeStreamingAll(init.io, help_text);
return;
@@ -186,7 +228,17 @@ fn nativeMain(init: std.process.Init) !void {
// instance resolves against ITS panes' directories, which are not ours.
// A word naming nothing on disk sends nothing and falls through to the
// classification below, which already refuses it — no second UI either way.
- if (!opts.nested) if (nested.outer()) |outer_pid| {
+ //
+ // `--detach` is exempt for the same reason `--nested` is, arrived at from
+ // the other side: it stacks no UI at all. A detached session started from a
+ // pane is a session, not a request that the outer instance open something,
+ // and handing it our positional would leave the caller with no session.
+ //
+ // `--attach` is exempt for the mirror of that: it stacks a UI, but the UI
+ // is a session that already exists somewhere else, and handing our word to
+ // the outer instance would open the file in the WRONG session and leave
+ // the caller with no frontend.
+ if (!opts.nested and detach == null and attach == null) if (nested.outer()) |outer_pid| {
const word = positional orelse {
try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
std.process.exit(1);
@@ -228,14 +280,41 @@ fn nativeMain(init: std.process.Init) !void {
opts.startup_config = found.bytes;
opts.startup_config_path = found.path;
opts.config_dir = found.dir;
+ // `--detach` is the core with no terminal and `--attach` is a terminal
+ // with no core, so the two together are a contradiction with no useful
+ // reading. Refused rather than resolved by declaration order, which would
+ // silently drop whichever flag lost.
+ if (detach != null and attach != null) return error.BadArgs;
+ // `--detach` replaces the frontend rather than choosing among them: the
+ // core runs here, with no terminal, and the frontends are elsewhere on a
+ // socket (src/detached/). It is checked before `platform` because it is not
+ // a shell — the tty and gui builds can both be asked for one.
+ if (detach) |name| {
+ // Bare `--detach` is named by this process's pid, which is the one name
+ // nobody has to be told and no two sessions can share. Unsigned: `{d}`
+ // prints a leading '+' for a positive SIGNED int, which is nested.zig's
+ // note about the same cast.
+ const named = if (name.len != 0)
+ name
+ else
+ try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))});
+ return @import("detached/server.zig").run(init, opts, named);
+ }
+ // A frontend is a SHELL, and only the tty one knows how to be one today.
+ // Comptime-folded, so a tty build carries none of this.
+ if (attach != null and pardes.platform != .tty) {
+ try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty shell\n");
+ std.process.exit(1);
+ }
switch (pardes.platform) {
- .tty => try @import("tty/tty.zig").run(init, opts),
+ .tty => try @import("tty/tty.zig").run(init, opts, attach),
.gui => try @import("gui/gui.zig").run(init, opts),
// Every other shell is entered by its host and never links this file
// at all: the browser through src/web.zig, the macOS app through
- // src/macos.zig, and the ESP32-P4 firmware through its own app root in
- // the zig-p4 package, which imports this package's `pardes_p4` module.
- .web, .macos, .p4 => unreachable,
+ // src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig,
+ // which is a root of its own in this repository and links the
+ // `pardes-esp32p4` object over the C ABI in src/esp32p4.zig.
+ .web, .macos, .esp32p4 => unreachable,
}
}
@@ -277,6 +356,19 @@ test {
// silence. That is the fonts.zig story above, told once already.
_ = @import("acmefs.zig");
_ = @import("fuse.zig");
+ // The detached-session transport (src/detached/), same story as fuse.zig
+ // above: it speaks the core's Event/Surface, so it belongs in THIS module
+ // rather than a standalone b.addTest, and nothing the core analyses reaches
+ // it. A TTY build does — tty.zig imports client.zig for `--attach` — but
+ // these names are what makes the transport's tests exist in every other
+ // build too, and `--detach` is not a tty-only feature.
+ // client.zig's own tests drive a real `Session` over a real socket, so
+ // naming it reaches server.zig too — but server.zig is named anyway, for
+ // the acmefs.zig reason: the day client.zig stops importing it is the day
+ // those tests vanish in silence.
+ _ = @import("detached/wire.zig");
+ _ = @import("detached/server.zig");
+ _ = @import("detached/client.zig");
if (comptime pardes.platform == .tty) {
_ = @import("tty/tty.zig");
// tty.zig calls the compositor only from its runtime loop, so merely
diff --git a/src/modal.zig b/src/modal.zig
index 953aa900..11eae743 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -886,7 +886,15 @@ pub fn nextGrapheme(text: []const u8, off: usize) usize {
if (off >= text.len) return text.len;
// The editor's own offsets are already boundaries. Keep the overwhelmingly
// common ASCII path O(1); only repair a continuation-byte input here.
- if (text[off] < 0x80 and (off + 1 == text.len or text[off + 1] < 0x80)) return off + 1;
+ //
+ // GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a
+ // following LF into the same cluster. `graphemeStart` spells that exclusion
+ // out (:875) and this did not, so the two disagreed about a CRLF file by
+ // exactly one byte — a head stepped onto the offset between CR and LF and
+ // `graphemeStart` then repaired it back onto the CR. Excluded here for the
+ // same reason and in the same words; everything else ASCII is still O(1).
+ if (text[off] < 0x80 and (off + 1 == text.len or text[off + 1] < 0x80) and
+ !(text[off] == '\r' and off + 1 < text.len and text[off + 1] == '\n')) return off + 1;
var start = off;
while (start > 0 and (text[start] & 0xC0) == 0x80) start -= 1;
if (start != off) start = graphemeStart(text, off);
@@ -903,7 +911,10 @@ pub fn prevGrapheme(text: []const u8, off: usize) usize {
bounded = repaired;
}
if (bounded == 0) return 0;
- if (text[bounded - 1] < 0x80 and (bounded == 1 or text[bounded - 2] < 0x80)) return bounded - 1;
+ // ...and the same GB3 exclusion, from the other side: a CR before this LF
+ // means the cluster starts one byte earlier than the fast path would say.
+ if (text[bounded - 1] < 0x80 and (bounded == 1 or text[bounded - 2] < 0x80) and
+ !(bounded >= 2 and text[bounded - 2] == '\r' and text[bounded - 1] == '\n')) return bounded - 1;
// Graphemes cannot cross a line break. Restrict the forward segmentation
// needed for a reverse step to the current line instead of rescanning the
// complete buffer.
@@ -1470,6 +1481,112 @@ test "extended grapheme boundaries cover combining emoji flag and CJK text" {
try std.testing.expectEqual(@as(usize, 19), graphemeAtColumn(text, 3));
}
+test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 walk" {
+ // Both functions answer ASCII from arithmetic and hand everything else to the segmenter. The
+ // guard is a claim about UAX #29 (an ASCII scalar is its own cluster unless the next scalar
+ // extends it, and every extender is non-ASCII), so pin it against the walk it skips rather
+ // than against transcribed offsets: same text, both routes, every offset including past the end.
+ const H = struct {
+ // `graphemeStart` with the ASCII arm deleted — nothing else changed.
+ fn start(text: []const u8, off: usize) usize {
+ const bounded = @min(off, text.len);
+ if (bounded == text.len) return text.len;
+ var it = uucode.grapheme.utf8Iterator(text);
+ while (it.nextGrapheme()) |g| {
+ if (bounded < g.end) return g.start;
+ }
+ return text.len;
+ }
+ // `nextGrapheme` with the ASCII arm deleted.
+ fn next(text: []const u8, off: usize) usize {
+ if (off >= text.len) return text.len;
+ var s = off;
+ while (s > 0 and (text[s] & 0xC0) == 0x80) s -= 1;
+ if (s != off) s = start(text, off);
+ var it = uucode.grapheme.utf8Iterator(text[s..]);
+ const g = it.nextGrapheme() orelse return @min(s + 1, text.len);
+ return s + g.end;
+ }
+ fn check(text: []const u8) !void {
+ var off: usize = 0;
+ while (off <= text.len + 2) : (off += 1) {
+ std.testing.expectEqual(start(text, off), graphemeStart(text, off)) catch |e| {
+ std.debug.print("graphemeStart({any}, {d})\n", .{ text, off });
+ return e;
+ };
+ std.testing.expectEqual(next(text, off), nextGrapheme(text, off)) catch |e| {
+ std.debug.print("nextGrapheme({any}, {d})\n", .{ text, off });
+ return e;
+ };
+ }
+ }
+ };
+
+ // Scalars that extend a preceding ASCII base into ONE cluster, which is the whole reason the
+ // fast path inspects its neighbour: a combining mark, a ZWJ sequence, a spacing mark
+ // (Devanagari visarga), a variation selector. Plus wide glyphs, a regional-indicator pair,
+ // and three shapes of invalid UTF-8 the segmenter must still be trusted with: a bad start
+ // byte, a truncated tail, a bad continuation.
+ const neighbours = [_][]const u8{
+ "", "a", "\u{301}", "\u{200d}\u{1f680}",
+ "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}\u{754c}",
+ "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8",
+ "\xe4\x28\xb8",
+ };
+ // Every byte the range test can see, ASCII and not: 0x20..0x7e take the fast path, and \t, \r,
+ // the rest of the C0 controls and DEL are excluded by it and must still reach the same answer.
+ 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]);
+ // ...and the same byte as a follower, so a boundary is probed from both sides.
+ @memcpy(buf[0..tail.len], tail);
+ buf[tail.len] = b;
+ try H.check(buf[0 .. tail.len + 1]);
+ }
+ }
+
+ // Text that has no CR-LF pair in it: GB3 is the one ASCII-only rule that joins two clusters,
+ // and it gets its own test below because it is the single exclusion every fast path has to
+ // carry by hand.
+ for ([_][]const u8{ "a\r", "\ra", "\n\r", "a\rb\nc" }) |text| try H.check(text);
+
+ // Mixed text long enough that a fast-path run starts, ends and restarts inside one string.
+ try H.check("plain ascii then \u{4e16}\u{754c} then e\u{301} then more ascii");
+}
+
+// GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a following LF into the same
+// cluster. Each of the three steppers carries that exclusion separately - `graphemeStart` at :875,
+// `nextGrapheme`'s ASCII arm at :896, `prevGrapheme`'s at :916 - so nothing but a test keeps them
+// agreeing. The invariant is that all three answer the same CRLF boundary: for every cluster the
+// segmenter reports, `graphemeStart` maps its start to itself, `nextGrapheme` maps that start to
+// its end, and `prevGrapheme` maps its end back to the start.
+//
+// This was a live bug: `nextGrapheme` and `prevGrapheme` stepped exactly one byte whenever the
+// byte at the offset and its neighbour were ASCII, so on a CRLF file the flat-buffer range engine
+// could step a head to offset 1 and `graphemeStart` would repair that same offset back to 0. Both
+// arms now spell the exclusion out, and this test is what holds them there.
+test "GB3 keeps CR-LF one cluster for every grapheme step" {
+ const text = "a\r\nb";
+ // The reference: the same segmentation the slow arms of these functions run.
+ var it = uucode.grapheme.utf8Iterator(text);
+ var starts: [8]usize = undefined;
+ var ends: [8]usize = undefined;
+ var n: usize = 0;
+ while (it.nextGrapheme()) |g| : (n += 1) {
+ starts[n] = g.start;
+ ends[n] = g.end;
+ }
+ for (starts[0..n], ends[0..n]) |start, end| {
+ try std.testing.expectEqual(start, graphemeStart(text, start));
+ try std.testing.expectEqual(end, nextGrapheme(text, start));
+ try std.testing.expectEqual(start, prevGrapheme(text, end));
+ }
+}
+
test "Unicode find and word motion stay on grapheme boundaries" {
const lines = [_][]const u8{"\u{e9}x\u{e9}"};
try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(&lines, .{ .row = 0, .col = 0 }, 'é', true, false, 1).?);
diff --git a/src/nested.zig b/src/nested.zig
index eb01b2e0..887d0703 100644
--- a/src/nested.zig
+++ b/src/nested.zig
@@ -36,21 +36,28 @@ const libc = std.c;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
-const darwin = switch (builtin.os.tag) {
+/// THE SOCKET CONVENTIONS BELOW ARE SHARED, and the ones marked `pub` are
+/// shared with src/detached/server.zig — a second unix socket in the same
+/// per-user directory, under a different name (`pardes-detached-<name>.sock`
+/// rather than `pardes-<pid>.sock`). They were copied into that file when it
+/// landed; one directory vetted by two different predicates is exactly the
+/// divergence the reasoning here is meant to prevent, so there is one of each.
+pub const darwin = switch (builtin.os.tag) {
.macos, .ios, .tvos, .watchos, .visionos => true,
else => false,
};
/// This module is only as portable as its two ingredients: a way to name the
-/// executable and parent of an arbitrary pid, and unix sockets.
-const supported = builtin.os.tag == .linux or darwin;
+/// executable and parent of an arbitrary pid, and unix sockets. The detached
+/// transport needs the second alone, and the same answer.
+pub const supported = builtin.os.tag == .linux or darwin;
/// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard
/// limit on this whole feature: a path that does not fit is not a socket
/// address, it is a truncated one pointing somewhere else. Taken from the
/// struct so that the buffers, the fit checks and the memcpy below cannot
/// disagree with the kernel or with each other.
-const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len;
+pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len;
/// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of
/// `/proc/<pid>/exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux
@@ -72,10 +79,15 @@ extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_in
extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int;
/// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards.
-/// The gap is a race only against a fork on another thread, and both callers
-/// are past that: `listen` runs before the first pane exists, and `acceptLine`
-/// runs on a thread of its own long after spawning has settled.
-fn setCloexec(fd: c_int) void {
+/// The gap is a race only against a fork on another thread, and every caller
+/// is past that: `listen` runs before the first pane exists, `acceptLine` runs
+/// on a thread of its own long after spawning has settled, and the detached
+/// session forks nothing at all (its ptys live in its frontends).
+///
+/// CLOEXEC still matters for both: a `--detach` session is long-lived, and an
+/// inherited listener would keep its socket bound long after it ended — the
+/// same shape as the inherited lock fd that once held a flock forever.
+pub fn setCloexec(fd: c_int) void {
const FD_CLOEXEC: c_int = 1;
_ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC);
}
@@ -87,10 +99,12 @@ pub const max_line = 4200;
/// Where the sockets live. `$XDG_RUNTIME_DIR` first — a per-user 0700 tmpfs
/// the login session already cleans up — else `~/.local/state/pardes`, which
-/// is per-user for the same reason a home directory is. Asked by the client
-/// (to derive the path), by the listener (to create and vet it) and by the
-/// sweeper (to scan it), so it is written once.
-fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 {
+/// is per-user for the same reason a home directory is. NEVER /tmp: these
+/// sockets take a command line, or keystrokes into a live editor. Asked by the
+/// client (to derive the path), by the listener (to create and vet it), by the
+/// sweeper (to scan it) and by the detached transport (all three, for its own
+/// name), so it is written once.
+pub fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 {
if (libc.getenv("XDG_RUNTIME_DIR")) |x|
return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null;
const home = libc.getenv("HOME") orelse return null;
@@ -311,14 +325,19 @@ pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool {
return true;
}
-/// The three things ensureSocketDir has to know about a path, from whichever
+/// The two things `ensureSocketDir` has to know about a path, from whichever
/// call the platform actually offers. Darwin has fstatat and no statx; on
/// linux std.c.fstatat is `void` — glibc hides it behind a versioned symbol
-/// std cannot name — so linux asks statx for the same three fields. Both
-/// spellings refuse to follow a symlink, which is the point of asking.
-const DirFacts = struct { mode: u32, uid: libc.uid_t };
+/// std cannot name — so linux asks statx for the same fields. Both spellings
+/// refuse to follow a symlink, which is the point of asking.
+///
+/// `pub` for the detached transport, which vets the same directory and also
+/// vets the SOCKET FILE with it (src/detached/server.zig `vetted`): `mode`
+/// carries the type bits, so one call answers "is this a socket, ours, and
+/// private" as well as it answers it for a directory.
+pub const DirFacts = struct { mode: u32, uid: libc.uid_t };
-fn statNoFollow(path: [:0]const u8) ?DirFacts {
+pub fn statNoFollow(path: [:0]const u8) ?DirFacts {
if (comptime darwin) {
var st: libc.Stat = undefined;
if (libc.fstatat(libc.AT.FDCWD, path, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return null;
@@ -334,9 +353,11 @@ fn statNoFollow(path: [:0]const u8) ?DirFacts {
/// Create the socket directory if it is missing and refuse it unless it is a
/// directory WE own with nothing granted to group or other. A planted path is
-/// the whole attack on a socket that runs commands, and $XDG_RUNTIME_DIR
-/// passes this untouched (the login session already makes it 0700).
-fn ensureSocketDir(dir: [:0]const u8) bool {
+/// the whole attack on a socket that runs commands — or, for the detached
+/// transport that shares this, on one that carries keystrokes into a live
+/// editor — and $XDG_RUNTIME_DIR passes this untouched (the login session
+/// already makes it 0700).
+pub fn ensureSocketDir(dir: [:0]const u8) bool {
// mkdir -p, because the HOME branch is three levels deep and a machine
// without ~/.local/state would otherwise switch the feature off in
// silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply
diff --git a/src/output_pane_integration_test.zig b/src/output_pane_integration_test.zig
index ab6daa15..e7a46a03 100644
--- a/src/output_pane_integration_test.zig
+++ b/src/output_pane_integration_test.zig
@@ -72,7 +72,7 @@ test "EffectCode opens the embedded implementation used by this backend" {
.macos => "src/macos/Sources/ScenePostprocessor.swift",
// Neither backend builds this native test binary: the browser shell is wasm and the P4
// firmware is a freestanding object, so no `unit-test` run can ever land here.
- .web, .p4 => unreachable,
+ .web, .esp32p4 => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null);
}
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 },
+ );
+}
diff --git a/src/runtime_config.zig b/src/runtime_config.zig
index bd1ba00b..69d2cdfe 100644
--- a/src/runtime_config.zig
+++ b/src/runtime_config.zig
@@ -4,22 +4,7 @@
//! data it mutates and the Config report reads. Neither contains callbacks.
const std = @import("std");
const panel_animation = @import("panel_animation.zig");
-
-/// HOW LONG A HOST-SUPPLIED ABSOLUTE PATH MAY BE, and the only reason this
-/// 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 (@import("pardes_config").platform == .p4) 0 else 4095;
+const limits = @import("limits.zig");
/// Tagline glyphs retain body-cell geometry, so allowing a face larger than
/// the body would clip into neighbouring cells. Zero would make the role
@@ -64,15 +49,15 @@ pub const State = struct {
shell: struct {
requested: Text(255) = .{},
// One data schema across native, browser and freestanding builds; only
- // its one absolute-path field follows `host_path_cap`.
- effective: Text(host_path_cap) = .{},
+ // its one absolute-path field follows `limits.host_path_cap`.
+ effective: Text(limits.host_path_cap) = .{},
pending: bool = true,
} = .{},
/// The shell acknowledges a font before `effective` changes. A rejected
/// request therefore remains queryable without claiming it is on screen.
font: struct {
- requested_path: Text(host_path_cap) = .{},
+ requested_path: Text(limits.host_path_cap) = .{},
requested_name: Text(255) = .{},
effective_name: Text(255) = .{},
pending: bool = false,
diff --git a/src/source_manifest.zig b/src/source_manifest.zig
index e2cc312c..c3bfde88 100644
--- a/src/source_manifest.zig
+++ b/src/source_manifest.zig
@@ -15,18 +15,13 @@
//! Paths are as a user would type them, repo-root-relative, which is what
//! `look` resolves a click against.
-//! 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.
+const limits = @import("limits.zig");
pub const Source = struct { path: []const u8, contents: []const u8 };
-/// A slice, not an array: the P4 table is empty and every consumer only ever
-/// iterates or takes `.len`.
-pub const all: []const Source = if (@import("pardes_config").platform == .p4) &.{} else &allowlist;
+/// A slice, not an array: the P4 table is empty (see `limits.embedded_sources`
+/// for why) and every consumer only ever iterates or takes `.len`.
+pub const all: []const Source = if (limits.embedded_sources) &allowlist else &.{};
const allowlist = [_]Source{
.{ .path = "build.zig", .contents = @embedFile("root-build.zig") },
diff --git a/src/term_pane.zig b/src/term_pane.zig
index cda45394..624a68a1 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -14,7 +14,7 @@
//! ghostty-vt VALUE: pardes.zig no longer imports the emulator at all, and
//! image.zig's import exists solely to comptime-check a colour table against
//! it. (src/gui/gui.zig and the test/ snapshot harness import it too — both
-//! are backends, and neither is in the p4 graph.) `pardes.terminal_panes` says
+//! are backends, and neither is in the esp32p4 graph.) `pardes.terminal_panes` says
//! whether a build has an emulator at all; the two Pane slots and the
//! accessors under "the emulator, as the core is allowed to see it" are the
//! whole seam, and `!enabled` answers every one of them with the empty grid.
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index c5abf0fe..f8042d71 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -21,6 +21,13 @@ const fuse = @import("../fuse.zig");
const fs_service = @import("../fs_service.zig");
const panel_compositor = @import("panel_compositor.zig");
const host_api = @import("../host.zig");
+const config = @import("../config.zig");
+// The other half of `--detach`, and the reason this file has an `--attach`
+// branch at all: the frontend side of a detached session is a terminal and a
+// socket, and this file is already the one that owns a terminal.
+const detached_client = @import("../detached/client.zig");
+const detached_server = @import("../detached/server.zig");
+const wire = @import("../detached/wire.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
@@ -445,10 +452,29 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee
return null;
}
-pub fn run(init: std.process.Init, opts: pardes.Options) !void {
+/// `attach` is `--attach[=<name>]`: empty means "the session there is" (see
+/// `sessionName`). It is a parameter rather than an `Options` field because it
+/// says nothing to the core — this process does not have one when it is set.
+pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void {
const io = init.io;
const gpa = init.gpa;
+ // `--attach` only, and registered HERE — before the terminal is opened —
+ // for the LIFO: it must run after every deferred restore below. Why a
+ // frontend stopped is discovered deep inside the loop while the alt screen
+ // is still up, and anything written there is erased by the switch back to
+ // the main screen, which is the one screen a user would look at.
+ var attach_end: AttachEnd = .none;
+ defer attach_end.report(io);
+
+ // ...and resolved before the terminal too, for the same reason turned the
+ // other way: "no session called work" is a launch that never started, and
+ // flashing the alt screen up and straight back down to say so is worse
+ // than never entering it.
+ var name_buf: [detached_server.path_max]u8 = undefined;
+ var attach_name: []const u8 = &.{};
+ if (attach) |requested| attach_name = sessionName(&name_buf, requested, &attach_end) orelse return;
+
const allocs = pardes.allocators.init(gpa);
defer pardes.allocators.deinit();
var options = opts;
@@ -495,6 +521,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// tty is still open — sends the disable off that flag.
try vx.setBracketedPaste(tty.writer(), true);
+ // `--attach`: this process has a terminal and NO core. Everything above is
+ // the terminal, which an attached frontend needs exactly as much as a whole
+ // session does; everything below is the core, which lives in the detached
+ // process. The branch is here so both leave by the same door — an attach
+ // has to restore cooked mode, the main screen and the mouse the way an
+ // ordinary exit does, and sharing the deferred teardown is the only way to
+ // guarantee that instead of asserting it.
+ if (attach != null) {
+ attach_end = attachSession(init, opts, attach_name, &tty, &vx);
+ return;
+ }
+
pardes.image.start(io, allocs.image);
if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
pardes.syntax.start(allocs.tree_sitter);
@@ -796,11 +834,6 @@ const Shell = struct {
/// the tracks the frame in flight was composed from
tracks: []const pardes.panel_animation.Track = &.{},
- /// 4 MiB ceiling, past which the tail is dropped rather than grown into. A
- /// paste that large is a mis-click on a file, not an edit, and the core
- /// would have to hold the whole of it as one undo entry.
- const max_paste_bytes: usize = 4 << 20;
-
fn of(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
}
@@ -924,61 +957,13 @@ const Shell = struct {
core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
},
.key_press => |key| if (s.in_paste) {
- // Between the markers a key is DATA, never a command. Same
- // two inputs as the dispatch below, so a pasted character
- // is exactly the character the core would have been given.
- const text = key.text orelse "";
- const cp = mapKey(effCp(key));
- const bytes: []const u8 = if (text.len > 0)
- text
- else if (cp == pardes.Key.tab)
- "\t"
- else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j'))
- // vaxis gives control bytes no text at all: a line
- // break inside a paste reaches the ground parser as a
- // bare CR (-> Key.enter) or, from a terminal that does
- // not translate them, a bare LF — which that parser
- // reports as ctrl+j. Nothing in here is a real
- // keypress, so both of them are just a newline.
- "\n"
- else
- // arrows, F-keys, a stray escape: noise a paste has no
- // business carrying, dropped rather than smuggled in.
- "";
+ const bytes = pasteBytes(key);
const room = max_paste_bytes -| s.paste_buf.written().len;
s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {};
- } else core.update(.{ .key = .{
- .cp = mapKey(effCp(key)),
- .text = key.text orelse "",
- .ctrl = key.mods.ctrl,
- .alt = key.mods.alt,
- .shift = key.mods.shift,
- } }),
+ } else core.update(keyEvent(key)),
.mouse => |m| {
const pdf_before = nativePdfWheelTarget(core, m);
- const button: ?pardes.Mouse.Button = switch (m.button) {
- .left => .left,
- .middle => .middle,
- .right => .right,
- .wheel_up => .wheel_up,
- .wheel_down => .wheel_down,
- .wheel_left => .wheel_left,
- .wheel_right => .wheel_right,
- .none => .none, // button-less motion: hover tracking
- else => null,
- };
- if (button) |b| core.update(.{ .mouse = .{
- .button = b,
- .kind = switch (m.type) {
- .press => .press,
- .release => .release,
- .motion => .motion,
- .drag => .drag,
- },
- .col = @intCast(m.col),
- .row = @intCast(m.row),
- .ctrl = m.mods.ctrl,
- } });
+ if (mouseEvent(m)) |ev| core.update(ev);
if (pdf_before) |before| {
if (core.panes[before.pane]) |pane| {
if (pane.pdfPage()) |page| {
@@ -1108,19 +1093,7 @@ const Shell = struct {
) catch return;
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
- win.clear();
- var y: u16 = 0;
- while (y < surface.rows) : (y += 1) {
- var x: u16 = 0;
- while (x < surface.cols) : (x += 1) {
- const cell = surface.at(x, y);
- if (cell.default) continue;
- win.writeCell(x, y, .{
- .char = .{ .grapheme = cell.grapheme() },
- .style = vaxisStyle(cell.style),
- });
- }
- }
+ paintCells(win, surface.cells, surface.cols, surface.rows);
tz_cells.end();
// Pixel attachments (kitty graphics): transmit once per pixel
// generation, then re-place every frame (placements aren't
@@ -1189,11 +1162,7 @@ const Shell = struct {
vx.freeImage(s.tty.writer(), removed.value.id);
if (stale_len < stale.len) break;
}
- if (surface.cursor) |cur| {
- win.showCursor(cur.x, cur.y);
- // insert = beam, everything else = the terminal's default shape
- win.setCursorShape(if (cur.bar) .beam else .default);
- }
+ if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
const tz_render = tracy.zone(@src(), "vx.render");
vx.render(s.tty.writer()) catch {};
tz_render.end();
@@ -1267,14 +1236,7 @@ const Shell = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
- var pathbuf: [4096:0]u8 = undefined;
- if (path.len >= pathbuf.len) return;
- @memcpy(pathbuf[0..path.len], path);
- pathbuf[path.len] = 0;
- const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- writeFd(fd, bytes);
- _ = libc.close(fd);
+ if (!writeFileBytes(path, bytes)) return;
// our own write is about to come back as a watch event: restamp from
// the bytes we just put there so it reads as "no change". Only when
// this IS the pane's watched file — a `Save <elsewhere>` must not
@@ -1296,10 +1258,7 @@ const Shell = struct {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
const path = pardes.dump.outPath(&pbuf) orelse return;
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- writeFd(fd, bytes);
- _ = libc.close(fd);
+ if (!writeFileBytes(path, bytes)) return;
s.core.setLastDump(path);
}
@@ -1544,7 +1503,15 @@ fn wakeFs(ctx: ?*anyopaque) void {
_ = loop.tryPostEvent(.fs_ready) catch {};
}
-fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) struct { file: std.Io.File, pid: posix.pid_t } {
+/// `core` is null in an `--attach` frontend, which forks the pane shells for a
+/// core that is in another process entirely. The two things it is used for are
+/// both messages BACK to that core — which pane serial the child's environment
+/// should name, and which binary was actually executed — and neither has a
+/// place on the detached wire (see wire.zig): a detached session's `--fs`
+/// stays in the session, and `acknowledgeShell` has no `ClientTag`. So both
+/// are skipped rather than faked, and the pane tag in a detached session
+/// simply does not name its shell.
+fn forkShell(core: ?*pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) struct { file: std.Io.File, pid: posix.pid_t } {
var master: c_int = undefined;
// resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, and a PATH search would
@@ -1554,7 +1521,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro
// second reason on top of that one: acme puts `winid` in the child, which
// is safe there only because rfork(RFENVG) has just given it a private
// environment group. See fs_service.exportPaneEnv.
- fs_service.exportPaneEnv(fs, if (core.panes[pane]) |pn| pn.serial else 0);
+ fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -1568,7 +1535,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
- if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
+ if (pid > 0) if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1621,6 +1588,125 @@ fn mapKey(cp: u21) u21 {
};
}
+/// 4 MiB ceiling, past which the tail is dropped rather than grown into. A
+/// paste that large is a mis-click on a file, not an edit, and the core would
+/// have to hold the whole of it as one undo entry. File scope rather than a
+/// `Shell` decl because the `--attach` frontend accumulates the same bursts
+/// against the same ceiling, and wire.zig cites this name as THE cap.
+const max_paste_bytes: usize = 4 << 20;
+
+/// One key press between the bracketed-paste markers, as the bytes it means.
+/// A key in there is DATA, never a command, and the two callers — the
+/// in-process host and the `--attach` frontend — must agree exactly, because
+/// what they produce is compared against what a terminal's own paste would
+/// have delivered.
+fn pasteBytes(key: vaxis.Key) []const u8 {
+ const text = key.text orelse "";
+ if (text.len > 0) return text;
+ const cp = mapKey(effCp(key));
+ if (cp == pardes.Key.tab) return "\t";
+ // vaxis gives control bytes no text at all: a line break inside a paste
+ // reaches the ground parser as a bare CR (-> Key.enter) or, from a
+ // terminal that does not translate them, a bare LF — which that parser
+ // reports as ctrl+j. Nothing in here is a real keypress, so both of them
+ // are just a newline.
+ if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n";
+ // arrows, F-keys, a stray escape: noise a paste has no business carrying,
+ // dropped rather than smuggled in.
+ return "";
+}
+
+/// ...and one ordinary key press as the core's event. Shared for the reason
+/// above: a keystroke must mean the same thing whether the core is in this
+/// process or on the other end of a socket.
+fn keyEvent(key: vaxis.Key) pardes.Event {
+ return .{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ .shift = key.mods.shift,
+ } };
+}
+
+/// ...and one mouse report. Null for a button this vocabulary has no name for
+/// (vaxis reports more of them than the core has), which is a report to drop
+/// rather than a press to invent.
+fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
+ const button: pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .wheel_left => .wheel_left,
+ .wheel_right => .wheel_right,
+ .none => .none, // button-less motion: hover tracking
+ else => return null,
+ };
+ return .{ .mouse = .{
+ .button = button,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ .ctrl = m.mods.ctrl,
+ } };
+}
+
+/// THE cell walk: canonical cells -> vaxis, cell for cell, with no
+/// interpretation. One walk and two callers, because a `frame` off the
+/// detached wire IS a `Surface`'s cells — wire.zig carries the grid and
+/// deliberately does not carry the two halves `Shell.present` adds around this
+/// (the kitty attachments, which have no encoding, and the panel transition,
+/// which the session composes before it sends). A second walk here would be
+/// two renderers for one canonical interface, and the interface is the thing
+/// this editor is.
+fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
+ win.clear();
+ var y: u16 = 0;
+ while (y < rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < cols) : (x += 1) {
+ const cell = &cells[@as(usize, y) * cols + x];
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+}
+
+fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void {
+ win.showCursor(x, y);
+ // insert = beam, everything else = the terminal's default shape
+ win.setCursorShape(if (bar) .beam else .default);
+}
+
+/// Create-or-truncate `path` and put `bytes` there. The half of `write_file`
+/// that is nothing but the filesystem, so that the frontend which has a disk
+/// and no core and the host which has both write a file the same way.
+/// Everything else in `Shell.writeFile` — the watch restamp, the "saved"
+/// message — is the CORE's memory of the write and stays with whoever owns
+/// one. False is a save that did not happen, which no caller may report as
+/// one.
+fn writeFileBytes(path: []const u8, bytes: []const u8) bool {
+ var pathbuf: [4096:0]u8 = undefined;
+ if (path.len >= pathbuf.len) return false;
+ @memcpy(pathbuf[0..path.len], path);
+ pathbuf[path.len] = 0;
+ const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return false;
+ writeFd(fd, bytes);
+ _ = libc.close(fd);
+ return true;
+}
+
fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
return .{
.fg = vaxisColor(s.fg),
@@ -1662,3 +1748,619 @@ fn writeFd(fd: c_int, data: []const u8) void {
off += @intCast(n);
}
}
+
+// ---------------------------------------------------------------------------
+// --attach: a terminal, a socket, and no core
+// ---------------------------------------------------------------------------
+
+/// Why an `--attach` frontend stopped, and where its exit status comes from.
+/// A VALUE rather than a message printed where it is discovered: at that point
+/// the alt screen is still up and everything written to it is erased by the
+/// restore a moment later. `run` registers `report` BEFORE it opens the
+/// terminal, so LIFO runs it last — on a cooked main screen, which is the one
+/// screen a person would go looking at.
+const AttachEnd = union(enum) {
+ /// not an `--attach` run at all, or the session said `quit`: exit 0
+ none,
+ /// `--attach=<name>` and nothing is listening under it
+ no_session: []const u8,
+ /// bare `--attach` with no session to mean...
+ nothing_detached,
+ /// ...or more than one, which is a choice and not ours to make
+ ambiguous: usize,
+ /// the session hung up on the connect and said why
+ refused: wire.Refusal,
+ /// the link died, or the stream stopped making sense
+ lost: anyerror,
+ /// the connect landed and the session hung up before the hello was
+ /// answered: a refusal whose reason raced the close (see `attachSession`)
+ rejected,
+
+ /// The nonzero exit lives HERE for the same reason the message does: every
+ /// teardown this process owes is a defer registered after this one, so by
+ /// the time this runs they have all run and there is nothing left to skip.
+ fn report(e: AttachEnd, io: std.Io) void {
+ var buf: [512]u8 = undefined;
+ const text: []const u8 = switch (e) {
+ .none => return,
+ .no_session => |name| std.fmt.bufPrint(
+ &buf,
+ "pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n",
+ .{ name, name },
+ ) catch "pardes: no detached session under that name\n",
+ .nothing_detached => "pardes: no detached session is running (start one with `pardes --detach`)\n",
+ .ambiguous => |n| std.fmt.bufPrint(
+ &buf,
+ "pardes: {d} detached sessions are running; say which with --attach=<name>\n",
+ .{n},
+ ) catch "pardes: several detached sessions are running; say which with --attach=<name>\n",
+ .refused => |why| switch (why) {
+ .version => "pardes: that session speaks a different wire version — it is another build of pardes\n",
+ .full => "pardes: that session already has every frontend slot taken\n",
+ .quitting => "pardes: that session is ending\n",
+ },
+ .lost => |err| std.fmt.bufPrint(&buf, "pardes: detached session lost: {t}\n", .{err}) catch
+ "pardes: detached session lost\n",
+ .rejected => "pardes: that session hung up on the connect — every frontend slot is taken (32), or it is shutting down. `PARDES_LOG=1` on the session names which\n",
+ };
+ std.Io.File.stderr().writeStreamingAll(io, text) catch {};
+ std.process.exit(1);
+ }
+};
+
+/// The session `--attach` meant. `--attach=<name>` is the name it says; bare
+/// `--attach` is THE session, because bare `--detach` names itself by its own
+/// pid and nobody can be expected to read a pid out of `$XDG_RUNTIME_DIR`.
+/// With exactly one session listening that is the one meant; with none or
+/// several this says which case it is instead of picking one. Null means "do
+/// not open a terminal", with `end` already saying why.
+///
+/// Both the directory and the filename convention come off ONE probe through
+/// `server.sessionPath`, rather than being re-derived here, for the reason that
+/// function exists at all: the side that binds and the side that looks must not
+/// be able to disagree about where a session lives.
+fn sessionName(buf: *[detached_server.path_max]u8, requested: []const u8, end: *AttachEnd) ?[]const u8 {
+ if (requested.len != 0) {
+ // A name is taken at its word — the connect in `attachSession` is the
+ // authority on whether anything is listening — except for the one case
+ // that is worth catching before a terminal is opened at all: a typo,
+ // where there is no socket file of that name whatsoever. Getting that
+ // wrong is the common failure, and the alternative is a full-screen
+ // alt-screen flash on the way to a one-line message.
+ var one_buf: [detached_server.path_max]u8 = undefined;
+ const one = detached_server.sessionPath(&one_buf, requested) orelse {
+ end.* = .{ .no_session = requested };
+ return null;
+ };
+ const F_OK: c_int = 0;
+ if (libc.access(one, F_OK) != 0) {
+ end.* = .{ .no_session = requested };
+ return null;
+ }
+ return requested;
+ }
+ const probe_name = "0";
+ var probe_buf: [detached_server.path_max]u8 = undefined;
+ const probe = detached_server.sessionPath(&probe_buf, probe_name) orelse {
+ end.* = .nothing_detached;
+ return null;
+ };
+ const base = std.fs.path.basename(probe);
+ const cut = std.mem.lastIndexOf(u8, base, probe_name).?;
+ const prefix = base[0..cut];
+ const suffix = base[cut + probe_name.len ..];
+ var dir_buf: [detached_server.path_max:0]u8 = undefined;
+ const dir = std.fs.path.dirname(probe) orelse "";
+ if (dir.len == 0 or dir.len >= dir_buf.len) {
+ end.* = .nothing_detached;
+ return null;
+ }
+ @memcpy(dir_buf[0..dir.len], dir);
+ dir_buf[dir.len] = 0;
+ const d = libc.opendir(dir_buf[0..dir.len :0]) orelse {
+ end.* = .nothing_detached;
+ return null;
+ };
+ defer _ = libc.closedir(d);
+ var found: usize = 0;
+ var len: usize = 0;
+ while (libc.readdir(d)) |ent| {
+ const entry = std.mem.sliceTo(&ent.name, 0);
+ if (entry.len <= prefix.len + suffix.len) continue;
+ if (!std.mem.startsWith(u8, entry, prefix) or !std.mem.endsWith(u8, entry, suffix)) continue;
+ const name = entry[prefix.len .. entry.len - suffix.len];
+ if (name.len > buf.len) continue;
+ found += 1;
+ @memcpy(buf[0..name.len], name);
+ len = name.len;
+ }
+ // A socket file whose session is gone still counts here: the sweep that
+ // unlinks corpses runs when the NEXT session binds (server.zig `sweep`),
+ // and probing every candidate with a connect would put a phantom frontend
+ // into a live session's slot table just to count it. One stale file
+ // therefore fails at `open` with "no such session", which is the truth.
+ if (found != 1) {
+ end.* = if (found == 0) .nothing_detached else .{ .ambiguous = found };
+ return null;
+ }
+ return buf[0..len];
+}
+
+/// `--attach[=<name>]`: the frontend half of a detached session. This process
+/// owns a terminal and a socket, and the `Pardes` is in the session process
+/// (src/detached/). The whole job is client.zig's two sentences — send the
+/// input it collects, draw the frames it is sent — plus the real host work a
+/// daemon has no way to do and asks a frontend for: fork a shell on a real tty,
+/// put bytes on a real disk, reach a real clipboard.
+///
+/// It is NOT a `Shell`, and the difference is not size. `Shell` IS the
+/// `Host.ctx` of a core in THIS process, and its methods reach into that core
+/// on nearly every line — a pane's message row, a watch generation taken off
+/// the pane's live text, `acknowledgeShell`, `setCwd`. With no core those are
+/// not cheaper versions of the same work, they are absent, and each one is
+/// named below where it goes missing. What the two do genuinely share is
+/// shared: the cell walk, the key and mouse vocabularies, the paste ceiling,
+/// `forkShell`, `readPty`, `writeFileBytes`.
+const Attach = struct {
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ client: *detached_client.Client,
+ loop: *Loop,
+ vx: *vaxis.Vaxis,
+ tty: *vaxis.Tty,
+ prompt_rcs: *const shell_bin.PromptRcs,
+ paste_buf: *std.Io.Writer.Allocating,
+ /// Where the config directory came from is main.zig's answer, carried in
+ /// `Options` — the only field of it this frontend reads, since every other
+ /// one describes a core that is elsewhere.
+ config_dir: ?[]const u8,
+ ptys: [pardes.MAX_PANES]?Pty = @splat(null),
+ gens: [pardes.MAX_PANES]u32 = @splat(0),
+ inotify_fd: c_int,
+ watches: file_watch.Table = @splat(null),
+ /// What each watched slot is watching. `file_watch.Table` remembers the
+ /// directory mark and the accepted generation but not the pathname — the
+ /// in-process host reads that back off the pane, and this one has no panes,
+ /// so the path the `watch_file` message carried is kept here.
+ watch_paths: [pardes.MAX_PANES]?[]u8 = @splat(null),
+ watch_task: ?std.Io.Future(anyerror!void) = null,
+ caps_pending: bool = true,
+ check_files: bool = false,
+ in_paste: bool = false,
+ /// A frame landed. Painted once at the end of the round rather than where
+ /// it arrives: several can be decoded out of one poll and only the last of
+ /// them is on the screen.
+ dirty: bool = false,
+
+ /// One event onto the wire. Non-null ends this frontend.
+ fn send(a: *Attach, ev: pardes.Event) ?AttachEnd {
+ a.client.send(.{ .event = ev }) catch |err| switch (err) {
+ // A message this protocol cannot carry, which is not a link that
+ // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB),
+ // and the one event that can reach it is a `file_changed` for a
+ // watched file between that and `look.readFile`'s own 256 MiB cap.
+ // Dropping it costs one reload; treating it as a hangup would cost
+ // the session.
+ error.Overlong, error.NoSpace => return null,
+ else => return .{ .lost = err },
+ };
+ return null;
+ }
+
+ /// One decoded message from the session. Every arm of `wire.ServerMsg` is
+ /// named and none of them is a catch-all: a session goes on asking for what
+ /// it is not given, and the two this frontend genuinely cannot do say so
+ /// where they are handled rather than vanishing into an `else`.
+ fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd {
+ switch (msg) {
+ // Already applied to the client's slot and geometry; the full frame
+ // the session promises a fresh attach is the next thing to arrive.
+ .welcome => {},
+ .refuse => |why| return .{ .refused = why },
+ // Applied too — `grid` and `cursor` are current by the time this
+ // returns, so all that is left is to say the screen moved.
+ .frame => a.dirty = true,
+ .quit => return .none,
+
+ .spawn => |v| a.spawn(v.pane, v.cwd),
+ .pty_write => |v| if (a.ptys[v.pane]) |pt| writeFd(pt.file.handle, v.bytes),
+ .pty_resize => |v| if (a.ptys[v.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = v.rows, .col = v.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws));
+ },
+ .write_file => |v| a.writeFile(v.pane, v.path, v.bytes),
+ // The dump lands on this frontend's disk. WHERE it landed is the
+ // core's own memory of it (`setLastDump`), and there is no
+ // `ClientTag` to carry a path back, so a detached `Dump` writes the
+ // file and the session cannot then name it.
+ .write_dump => |bytes| {
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse return null;
+ _ = writeFileBytes(path, bytes);
+ },
+ .watch_file => |v| a.watchFile(v.pane, v.path, v.on),
+ // NOT DOABLE FROM HERE, and it is the wire's shape rather than an
+ // omission: this message carries `{generation, on}`, the theme
+ // file's PATH lives in the core (`themeFileRequest`), and there is
+ // no message that would carry the reloaded bytes back. So a
+ // detached session does not live-reload a theme file. Named
+ // anyway, because the alternative is an `else` that would also
+ // swallow the next arm somebody adds to the protocol.
+ .watch_theme => {},
+ .dump_themes => a.dumpThemes(),
+ .set_clipboard => |text| if (text.len != 0) {
+ a.vx.copyToSystemClipboard(a.tty.writer(), text, a.gpa) catch {};
+ },
+ // The answer is not a reply message: it comes back as an ordinary
+ // `Event.paste`, which is the same asynchronous shape the
+ // in-process host has (see `Shell.readClipboard`), and it arrives
+ // through the `.paste` arm of `apply` like any other.
+ .read_clipboard => a.vx.requestSystemClipboard(a.tty.writer()) catch {},
+ .open_link => |url| look.openLink(url),
+ }
+ return null;
+ }
+
+ /// One vaxis event, translated onto the wire. Non-null ends the loop.
+ fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd {
+ switch (event) {
+ // Nothing posts these here, and each absence has a reason. `tick`
+ // is `Shell.waitInput`'s animation clock, and an animation runs
+ // where the core is — the session sleeps on its own frame interval
+ // (server.zig `nap`) and the frames simply arrive. `fs_ready`
+ // belongs to `--fs`, which wire.zig keeps in the detached process.
+ // `lsp_done`/`pipe_done` answer work the core dispatches, and it
+ // dispatches it there.
+ .nop, .tick, .fs_ready, .lsp_done, .pipe_done => {},
+ .quit => return .none,
+ .focus_in => {},
+ .focus_out => return a.send(.pointer_leave),
+ .winsize => |ws| {
+ a.vx.resize(a.gpa, a.tty.writer(), ws) catch {};
+ // Repaint from the frame already in hand: vaxis has just thrown
+ // its shadow grid away, and the SESSION grid may not move at
+ // all — it is the smallest common one and another frontend may
+ // be the small one (client.zig GEOMETRY).
+ a.dirty = true;
+ a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err };
+ },
+ .pty_read => |pr| {
+ defer a.gpa.free(pr.bytes);
+ return a.send(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
+ },
+ .pty_eof => |e| if (a.gens[e.id] == e.gen) {
+ if (a.ptys[e.id]) |*pt| {
+ pt.reader.await(a.io) catch {}; // reader just finished; join it or its future leaks
+ _ = libc.close(pt.file.handle);
+ a.ptys[e.id] = null;
+ }
+ return a.send(.{ .eof = .{ .pane = @intCast(e.id) } });
+ },
+ .key_press => |key| if (a.in_paste) {
+ const bytes = pasteBytes(key);
+ const room = max_paste_bytes -| a.paste_buf.written().len;
+ a.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {};
+ } else return a.send(keyEvent(key)),
+ .mouse => |m| if (mouseEvent(m)) |ev| return a.send(ev),
+ .paste => |bytes| {
+ defer a.gpa.free(@constCast(bytes));
+ return a.send(.{ .paste = bytes });
+ },
+ .paste_start => {
+ a.in_paste = true;
+ a.paste_buf.clearRetainingCapacity();
+ },
+ .paste_end => {
+ a.in_paste = false;
+ defer a.paste_buf.clearRetainingCapacity();
+ // ONE message for the whole paste, exactly as the in-process
+ // host makes it one `update`.
+ const pasted = a.paste_buf.written();
+ if (pasted.len > 0) return a.send(.{ .paste = pasted });
+ },
+ .command => |line| {
+ defer a.gpa.free(line);
+ return a.send(.{ .command = line });
+ },
+ .files_changed => a.check_files = true,
+ }
+ return null;
+ }
+
+ fn spawn(a: *Attach, pane: u8, cwd: []const u8) void {
+ // The in-process host's reaping rule, and its reason: the core reuses
+ // pane ids and there is no close effect, so a deleted pane's shell
+ // lives in its slot until a respawn lands here.
+ if (a.ptys[pane]) |*old| {
+ old.reader.cancel(a.io) catch {};
+ _ = libc.close(old.file.handle);
+ a.ptys[pane] = null;
+ }
+ a.gens[pane] +%= 1;
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0 and cwd.len < cwd_buf.len) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ // The SESSION's grid, which is what its panes are laid out against; the
+ // pane's own size follows immediately as a `pty_resize`.
+ //
+ // `config.default_shell` and not the session's configured one: `Shell
+ // <bin>` is a core setting, no message carries it, and inventing a
+ // second place that decides which shell runs would be worse than one
+ // that is occasionally the default. `shell_bin.resolve` falls back from
+ // there exactly as it does for a whole session.
+ const child = forkShell(null, pane, a.prompt_rcs, config.default_shell, cwd_z, a.client.rows, a.client.cols, null);
+ a.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
+ // Unconditional, unlike `Shell.spawn`'s `threads_ok`: every spawn here
+ // arrives over a socket this loop is already running, so there is no
+ // pre-loop drain to be in.
+ if (a.ptys[pane]) |*pt| {
+ pt.reader = a.io.concurrent(readPty, .{ a.io, a.gpa, pt.file, @as(usize, pane), a.gens[pane], a.loop }) catch pt.reader;
+ }
+ }
+
+ fn writeFile(a: *Attach, pane: u8, path: []const u8, bytes: []const u8) void {
+ if (!writeFileBytes(path, bytes)) return;
+ // Our own write is about to come back as a watch event: restamp from
+ // the bytes we just put there so it reads as "no change". Only when
+ // this IS the path this slot is watching — a `Save <elsewhere>` must
+ // not silence a real change to the file the pane has open. Same rule as
+ // `Shell.writeFile`; the comparison is against the path the session
+ // asked us to watch, because there is no pane here to ask.
+ //
+ // The "saved <path>" message that host also writes is a pane's message
+ // row, which belongs to the core: a detached save is silent.
+ const watched = a.watch_paths[pane] orelse return;
+ if (!std.mem.eql(u8, watched, path)) return;
+ if (a.watches[pane]) |*w| w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) };
+ }
+
+ fn watchFile(a: *Attach, pane: u8, path: []const u8, on: bool) void {
+ if (a.watch_paths[pane]) |old| a.gpa.free(old);
+ a.watch_paths[pane] = null;
+ if (!on or path.len == 0) return file_watch.watchPane(a.inotify_fd, &a.watches, pane, null, 0, .{ .text = 0 });
+ const owned = a.gpa.dupe(u8, path) catch return;
+ // Seeded from what is on disk RIGHT NOW, so the first `file_changed`
+ // this sends is the first edit that is not already in the core. The
+ // in-process host takes the same hash off the pane's live text; that
+ // text is a socket away, and the file it came from is not.
+ var hash: u64 = 0;
+ if (look.readFile(a.gpa, owned)) |bytes| {
+ hash = std.hash.Wyhash.hash(0, bytes);
+ a.gpa.free(bytes);
+ } else |_| {}
+ a.watch_paths[pane] = owned;
+ file_watch.watchPane(a.inotify_fd, &a.watches, pane, owned, 0, .{ .text = hash });
+ }
+
+ /// A coalesced inotify wake: re-read every watched path and hand the
+ /// session the ones that really changed. It sends BYTES rather than
+ /// reloading anything, because the text belongs to the core — which is
+ /// exactly why `ClientTag` has a `file_changed` at all.
+ fn reloadWatched(a: *Attach) ?AttachEnd {
+ if (!a.check_files) return null;
+ a.check_files = false;
+ for (a.watch_paths, 0..) |slot, pane| {
+ const path = slot orelse continue;
+ const w = if (a.watches[pane]) |*entry| entry else continue;
+ const bytes = look.readFile(a.gpa, path) catch continue;
+ defer a.gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ switch (w.generation) {
+ .text => |accepted| if (accepted == hash) continue,
+ // Never stored by this frontend: it cannot tell a PDF pane from
+ // a text one (the message carries a path and nothing else), so
+ // every slot is hashed and the core decides what the bytes mean
+ // — `applyWatchedFileChanged` reopens the path for a PDF pane
+ // and ignores them.
+ .pdf => {},
+ }
+ // Committed here rather than after an acknowledgement, because
+ // there is none: `file_changed` is a one-way event like every other
+ // input on this wire. A snapshot the core rejects is therefore not
+ // retried until the file changes again — the same bound the
+ // in-process host lives with whenever a reload fails.
+ w.generation = .{ .text = hash };
+ if (a.send(.{ .file_changed = .{ .pane = @intCast(pane), .bytes = bytes } })) |end| return end;
+ }
+ return null;
+ }
+
+ /// The themes land on this frontend's disk. Where they went is reported on
+ /// a pane's message row by the in-process host, and that row is the core's,
+ /// so a detached dump is silent — the same shape as `write_dump` above.
+ fn dumpThemes(a: *Attach) void {
+ const dir = a.config_dir orelse return;
+ const out = user_config.dumpThemes(a.io, a.gpa, dir, pardes.themes) catch return;
+ a.gpa.free(out);
+ }
+
+ /// The capability handshake, resolved on the loop exactly as
+ /// `Shell.pollFrame` resolves it and for its reason: the replies land on
+ /// vaxis's reader thread, and this is the only thread allowed to write to
+ /// the tty. No `native_images` here — this wire carries no attachments.
+ fn enableCaps(a: *Attach) void {
+ if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return;
+ a.caps_pending = false;
+ a.vx.enableDetectedFeatures(a.tty.writer()) catch {};
+ // Earlier frames were drawn under the pre-handshake caps, and vaxis's
+ // shadow grid has to be re-established under the new ones or it keeps
+ // skipping cells it thinks are current.
+ a.vx.queueRefresh();
+ a.dirty = true;
+ }
+
+ fn paint(a: *Attach) void {
+ a.dirty = false;
+ const win = a.vx.window();
+ // The session grid can be smaller than this window; `paintCells` clears
+ // first, so the surplus is the terminal's own default cell rather than
+ // whatever was there a frame ago.
+ paintCells(win, a.client.grid.items, a.client.cols, a.client.rows);
+ if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
+ a.vx.render(a.tty.writer()) catch {};
+ }
+};
+
+/// How long the frontend may sleep on the socket before it looks at the
+/// terminal. This loop has TWO event sources and can block on only one of
+/// them: vaxis delivers the terminal's events on its reader thread into a
+/// mutex/condvar queue, which has no descriptor to hand `poll(2)` alongside
+/// the socket — client.zig's `wait` takes a timeout for exactly that reason
+/// ("the terminal it draws on is polled by whoever owns that"), and this is
+/// whoever.
+///
+/// 8 ms is half a 60 Hz frame: a keystroke waits at most one of those before it
+/// is on the wire (4 ms on average), and the frame it causes needs no wait at
+/// all — it lands in the poll the moment the session writes it. The price of
+/// the ceiling is 125 poll rounds a second on a frontend nobody is touching,
+/// and it is measurably below the noise of what an idle pardes already costs:
+/// on this machine (i7-11700, 100 Hz jiffies) an idle attached frontend used
+/// 0.16% of one core over 60 s and 0.18% over 120 s, against 0.11% and 0.31%
+/// for an idle in-process session on the same screen over the same windows.
+/// Reach for an eventfd and a waker thread — fuse.zig's `pollLoop` is the
+/// pattern — only if that ever stops being true.
+const attach_poll_ms = 8;
+
+/// The whole `--attach` session: connect, then one loop over the two event
+/// sources until the session, the link or the terminal ends it. The terminal is
+/// already raw, on the alt screen and reporting the mouse — `run` did that, and
+/// `run`'s defers undo it, which is what makes an attach leave a terminal in
+/// exactly the state an ordinary exit does.
+fn attachSession(init: std.process.Init, opts: pardes.Options, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd {
+ const io = init.io;
+ const gpa = init.gpa;
+
+ // The hello carries this window, so the size has to be real before it goes
+ // out: a session told 80x24 by a 200x50 terminal reflows every pane twice,
+ // once now and once on the first SIGWINCH. `vx.resize` here rather than
+ // waiting for the loop's first event for the same reason — the first frame
+ // may arrive before any terminal event does, and it has to have somewhere
+ // to be painted.
+ const ws = tty.getWinsize() catch |err| return .{ .lost = err };
+ if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize };
+ vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err };
+
+ var client = detached_client.Client.open(gpa, name, ws.cols, ws.rows) catch |err| return switch (err) {
+ error.NoSession, error.NoSessionPath => .{ .no_session = name },
+ // The connect landed and the session hung up during the hello. That is
+ // what a refusal AT ACCEPT TIME looks like from here: server.zig's
+ // `refuseFd` writes six bytes and closes in the same pass, so the close
+ // can beat our hello onto the socket and `open` never gets far enough
+ // to read the reason. A refusal we do read arrives as `.refused` with
+ // the reason in it.
+ error.Closed => .rejected,
+ else => .{ .lost = err },
+ };
+ // `detach` and not `deinit`: seven bytes that turn "the peer vanished" into
+ // "the peer left" in the session's log.
+ defer client.detach();
+
+ var loop: Loop = .init(io, tty, vx);
+ var paste_buf: std.Io.Writer.Allocating = .init(gpa);
+ defer paste_buf.deinit();
+ // Private and complete before any fork, exactly as in `run`: the pane
+ // shells this frontend is asked to spawn borrow these stable path buffers.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ defer prompt_rcs.deinit();
+
+ var a: Attach = .{
+ .io = io,
+ .gpa = gpa,
+ .client = &client,
+ .loop = &loop,
+ .vx = vx,
+ .tty = tty,
+ .prompt_rcs = &prompt_rcs,
+ .paste_buf = &paste_buf,
+ .config_dir = opts.config_dir,
+ .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1,
+ };
+ // Registered BEFORE `loop.stop()` below so LIFO runs it after: the reader
+ // has to be joined before the queue is emptied, or a late post lands in a
+ // queue nobody drains again and its bytes leak. `run`'s teardown has the
+ // same shape and the same order, minus the workers this frontend never
+ // starts.
+ defer {
+ for (&a.ptys) |*slot| if (slot.*) |*pt| {
+ pt.reader.cancel(io) catch {};
+ _ = libc.close(pt.file.handle);
+ slot.* = null;
+ };
+ if (a.watch_task) |*t| {
+ t.cancel(io) catch {};
+ a.watch_task = null;
+ }
+ if (a.inotify_fd >= 0) {
+ _ = libc.close(a.inotify_fd);
+ a.inotify_fd = -1;
+ }
+ for (&a.watch_paths) |*slot| if (slot.*) |p| {
+ gpa.free(p);
+ slot.* = null;
+ };
+ while (loop.tryEvent() catch null) |ev| switch (ev) {
+ .pty_read => |pr| gpa.free(pr.bytes),
+ .command => |line| gpa.free(line),
+ .paste => |b| gpa.free(@constCast(b)),
+ else => {},
+ };
+ }
+
+ // A pardes started inside one of THIS frontend's pane shells finds this
+ // process as its outer instance — the shells are our children — so the
+ // nested-instance listener belongs here and not in the session, which has
+ // no children at all. The command line it accepts goes over the wire as an
+ // `Event.command`, which is what `ClientTag.command` is for.
+ const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
+ defer nested.unlisten(sock_fd);
+
+ loop.start() catch |err| return .{ .lost = err };
+ defer loop.stop();
+ // Both detached threads, for `run`'s reasons: sigwait and accept4 never
+ // return, so an `io.concurrent` task around either would hang the teardown
+ // that joins it.
+ (std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach();
+ if (sock_fd >= 0) (std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, &loop }) catch |err| return .{ .lost = err }).detach();
+ if (a.inotify_fd >= 0) a.watch_task = io.concurrent(watchFiles, .{ io, a.inotify_fd, &loop }) catch null;
+ // Send the capability probes and do not wait on them; `Attach.enableCaps`
+ // resolves them on the loop. run() has the long version of why.
+ vx.queryTerminalSend(tty.writer()) catch {};
+
+ while (true) {
+ const link = a.client.wait(attach_poll_ms);
+ // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in
+ // the same call that read the last bytes, and the last bytes are the
+ // session's `quit`: `fill` appends every chunk and only then sees the
+ // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly
+ // this reason, and honouring that means draining what arrived before
+ // deciding the link is what ended us — otherwise an ordinary `Kill`
+ // exits one frontend 0 (it got the quit alone) and whichever frontend
+ // was in the same poll round nonzero, which is what the first run of
+ // this loop actually did.
+ while (true) {
+ const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break;
+ if (a.handle(msg)) |end| return end;
+ }
+ link catch |err| return .{ .lost = err };
+ // The terminal, drained the way `Shell.waitInput` drains it and for its
+ // reason: a wheel flick is one batch rather than fifty round trips, and
+ // a paste in flight keeps draining without a message per character.
+ var batch: usize = 0;
+ while (a.in_paste or batch < 64) {
+ // Propagated rather than swallowed, unlike `Shell.waitInput`'s
+ // identical drain: there the blocking `nextEvent` above it is what
+ // notices a dead event source, and here there is no blocking read
+ // to notice with.
+ const ev = (loop.tryEvent() catch |err| return .{ .lost = err }) orelse break;
+ batch += 1;
+ if (a.apply(ev)) |end| return end;
+ }
+ if (a.reloadWatched()) |end| return end;
+ a.enableCaps();
+ if (a.dirty) a.paint();
+ }
+}