From 11f380f6d7222f2cad93c2cdf13701ea1f903d47 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 13:27:46 -0300 Subject: One core behind N frontends, the board's own runner moved in, and every board cap on one screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason. --- src/allocators.zig | 50 +- src/board_memory.zig | 34 +- src/builtins.zig | 4 +- src/detached/client.zig | 866 +++++++++++++++++ src/detached/server.zig | 1271 ++++++++++++++++++++++++ src/detached/wire.zig | 1755 ++++++++++++++++++++++++++++++++++ src/dump.zig | 24 +- src/effect_sources.zig | 4 +- src/esp32p4.zig | 1030 ++++++++++++++++++++ src/esp32p4/app.zig | 546 +++++++++++ src/esp32p4/input_rescue.zig | 271 ++++++ src/esp32p4/selftest.zig | 351 +++++++ src/esp32p4/uart.zig | 165 ++++ src/file_pane.zig | 97 +- src/limits.zig | 212 ++++ src/look.zig | 2 +- src/main.zig | 110 ++- src/modal.zig | 121 ++- src/nested.zig | 61 +- src/output_pane_integration_test.zig | 2 +- src/p4.zig | 1030 -------------------- src/pardes.zig | 361 ++++++- src/runtime_config.zig | 23 +- src/source_manifest.zig | 13 +- src/term_pane.zig | 2 +- src/tty/tty.zig | 882 +++++++++++++++-- 26 files changed, 7967 insertions(+), 1320 deletions(-) create mode 100644 src/detached/client.zig create mode 100644 src/detached/server.zig create mode 100644 src/detached/wire.zig create mode 100644 src/esp32p4.zig create mode 100644 src/esp32p4/app.zig create mode 100644 src/esp32p4/input_rescue.zig create mode 100644 src/esp32p4/selftest.zig create mode 100644 src/esp32p4/uart.zig create mode 100644 src/limits.zig delete mode 100644 src/p4.zig (limited to 'src') 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 [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-.sock` rather than `pardes-.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 ` 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[=]`: 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); +} + +/// `/pardes-detached-.sock`. The prefix differs from nested.zig's +/// `pardes-.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/esp32p4.zig b/src/esp32p4.zig new file mode 100644 index 00000000..8eab57e2 --- /dev/null +++ b/src/esp32p4.zig @@ -0,0 +1,1030 @@ +//! 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=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. +//! +//! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this +//! package in its `build.zig.zon` and import `pardes_p4`. That was built, and it broke every build +//! in that repo: nesting this package's ~30-package graph under one whose own claim is "host +//! dependencies: Zig, that is the whole list" made `std/Build.zig:2091` exceed its 1000-branch +//! comptime quota (through ghostty's `SharedDeps.zig:874` `lazyImport`), dragged in seven cached +//! tree-sitter versions whose `build.zig` uses APIs removed in 0.16, and materialised 2.6 GB across +//! 42,736 files into that repo's working copy. A linked object has none of those properties and one +//! extra virtue: the seam is bytes, so neither side can accidentally depend on the other's types. +//! +//! **Where the terminal is.** On the host. The board writes ANSI and reads ANSI; the terminal +//! emulator at the far end of the serial line does the font rendering, and answers this program's +//! own capability queries. That is why `vaxis` works here unmodified: `Vaxis.render`, +//! `queryTerminalSend` and `enableDetectedFeatures` all take a bare `*std.Io.Writer` +//! (`Vaxis.zig:375,278,329`), so the transport is a parameter. `vaxis.Tty` and `vaxis.Loop` are +//! termios/ioctl/SIGWINCH bound and are not used. +//! +//! **Where the memory is.** Not here either. The firmware measured its own RAM (240 KiB low, +//! 384 KiB high, and a 128 KiB region that turned out to be L2 cache) and owns the allocator; this +//! file receives four function pointers and rebuilds a `std.mem.Allocator` from them. Everything +//! the editor allocates comes from there. +//! +//! **Window size** arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like +//! any other input. Firmware has no `TIOCGWINSZ`, so the host-side bridge synthesises the first one. + +const std = @import("std"); +const builtin = @import("builtin"); +const pardes = @import("pardes.zig"); +const vaxis = @import("vaxis"); + +// ------------------------------------------------------------------ what a freestanding root owes +// +// These are ROOT-module declarations: std reads them off whichever file is the compilation root, and +// as of the build change that emits this file as the object, that is this file. They are not +// ceremony - each one was discovered by the build failing without it. + +/// The board has no MMU and no pages, but std derives allocator alignment from these two. 4 KiB is +/// the ESP32-P4's cache and DMA granularity. Without them: "riscv32-freestanding has unknown +/// page_size_min" from std/heap.zig:48. +/// +/// `logFn` is the load-bearing one. 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 on this target, and ONE `log.warn` anywhere in the core or in vaxis is enough to drag the +/// whole thing in and fail the build with "no member named 'getrandom'". +pub const std_options: std.Options = .{ + .page_size_min = 4096, + .page_size_max = 4096, + .logFn = logFn, +}; + +/// Logs go out the same byte sink as the frames, which is the only sink there is. Truncated rather +/// than allocated: a log line is never worth an allocation on a 384 KiB heap, and a logger that can +/// fail on OOM is a logger that disappears exactly when it is needed. +fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime fmt: []const u8, + args: anytype, +) void { + if (out_ctx == null and @intFromPtr(out_write) == 0) return; + 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 truncated]\r\n"; + out_write(out_ctx, line.ptr, line.len); +} + +pub const panic = std.debug.FullPanic(panicImpl); + +/// A panic here cannot unwind and has nowhere to go, so it reports through the write callback and +/// stops. `@trap` and not a spin: the firmware's own panic handler prints through the mask ROM, +/// which shares nothing with this path but the FIFO, so a trap leaves that diagnostic route intact. +fn panicImpl(msg: []const u8, _: ?usize) noreturn { + const prefix = "\r\nMARK PARDES_CORE_PANIC "; + out_write(out_ctx, prefix.ptr, prefix.len); + out_write(out_ctx, msg.ptr, msg.len); + out_write(out_ctx, "\r\n", 2); + @trap(); +} + +// ---------------------------------------------------------------------------------- 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_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_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_esp32p4_abi_version() callconv(.c) u32 { + return abi_version; +} + +/// The firmware's allocator, as C function pointers. `alignment` is a log2 value, matching +/// `std.mem.Alignment`'s own representation, so no translation table is needed. +/// +/// `remap` is absent on purpose: this allocator cannot move a block without copying it, so +/// `std.mem.Allocator`'s remap is implemented locally as "resize in place, or fail" and the caller's +/// own alloc/copy/free path handles the rest. +pub 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, +}; + +/// How finished runs of ANSI leave this object. +pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void; + +/// Flip one pad and report the level before and after; false if the firmware declines. OPTIONAL on +/// the wire, so a host with no pads (or one that has not implemented them yet) passes null and the +/// `Gpio` word answers "no pads" instead of the object having to know which firmwares exist. +/// +/// The board's side, not the editor's, because a correct toggle is the IO MUX, the GPIO matrix, the +/// pad's own bits and the output enable - four register files behind a per-pin table that the +/// firmware already has and checks against ESP-IDF. See `Host.VTable.pull_gpio_toggle`. +pub const GpioFn = *const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool; + +// ------------------------------------------------------------------- the allocator, rebuilt +// One `std.mem.Allocator` whose vtable forwards to the four pointers above. The indirection is the +// price of the seam and it is paid once per allocation, which on a first-fit heap is already the +// cheap part (measured on the die: 8,229 cycles for one allocation across 257 free blocks). + +var host_alloc: Allocator = undefined; + +fn hostAlloc(_: *anyopaque, len: usize, alignment: std.mem.Alignment, _: usize) ?[*]u8 { + return host_alloc.alloc(host_alloc.ctx, len, @intFromEnum(alignment)); +} + +fn hostResize(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) bool { + return host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len); +} + +fn hostRemap(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) ?[*]u8 { + return if (host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len)) mem.ptr else null; +} + +fn hostFree(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, _: usize) void { + host_alloc.free(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment)); +} + +const host_vtable: std.mem.Allocator.VTable = .{ + .alloc = hostAlloc, + .resize = hostResize, + .remap = hostRemap, + .free = hostFree, +}; + +/// `ptr` is never dereferenced - the four forwarders read the file-scope `host_alloc` - but +/// `std.mem.Allocator` requires a non-null context, so it points at the record itself. +fn gpa() std.mem.Allocator { + return .{ .ptr = @ptrCast(&host_alloc), .vtable = &host_vtable }; +} + +// ------------------------------------------------------------------------------- the ANSI sink +// A `std.Io.Writer` over the firmware's write callback. Buffered, because vaxis emits a frame as a +// long run of small writes - cursor move, SGR run, grapheme, repeat - and an unbuffered writer would +// make a C call per fragment. + +var out_write: WriteFn = undefined; +var out_ctx: ?*anyopaque = null; +var host_gpio: ?GpioFn = null; +var out_buf: [8192]u8 = undefined; +var out: std.Io.Writer = undefined; + +fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + // The shape std documents at Io/Writer.zig:46-63: buffer first, then every slice of `data`, with + // the LAST slice repeated `splat` times, and the count returned excluding the buffered bytes. + if (w.end > 0) { + out_write(out_ctx, w.buffer.ptr, w.end); + w.end = 0; + } + const head = data[0 .. data.len - 1]; + const pattern = data[head.len]; + var written: usize = 0; + for (head) |bytes| { + if (bytes.len > 0) out_write(out_ctx, bytes.ptr, bytes.len); + written += bytes.len; + } + var i: usize = 0; + while (i < splat) : (i += 1) { + if (pattern.len > 0) out_write(out_ctx, pattern.ptr, pattern.len); + } + return written + pattern.len * splat; +} + +// ------------------------------------------------------------------------------------ the state + +var core: ?*pardes.Pardes = null; +var vx: vaxis.Vaxis = undefined; +var parser: vaxis.Parser = .{}; + +/// vaxis wants an environment map. There is no environment; an empty one is the honest answer and +/// the only thing vaxis reads it for is TERM-derived heuristics, which the capability queries +/// supersede. +var env_map: std.process.Environ.Map = undefined; + +/// Input that arrived mid-sequence. An escape sequence can be split across UART reads, and the +/// parser reports "incomplete" by consuming nothing, so the tail has to survive until more arrives. +var in_buf: [1024]u8 = undefined; +var in_len: usize = 0; + +/// Bracketed paste: between the markers, keys are DATA and never commands. +var paste_buf: std.ArrayListUnmanaged(u8) = .empty; +var in_paste: bool = false; + +/// Set by anything that could change the screen; cleared by a render. The firmware asks before +/// rendering, because on a 115200-baud link an unconditional repaint per loop saturates the wire and +/// starves input. +var dirty: bool = true; + +/// The largest grid this board can render, and the reason it is not just the host's terminal size. +/// +/// A cell is paid for TWICE now, not four times: pardes keeps its `Surface` and this shell keeps a +/// shadow copy of it to diff against. vaxis used to keep a `Screen` and an `InternalScreen` as well, +/// 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 `-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").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 }; + +/// How big vaxis's own grids need to be. +/// +/// ONE CELL under `direct_emit`, because neither of them is ever read: vaxis keeps a `Screen` and an +/// `InternalScreen`, and the emitter diffs the Surface against its own shadow and writes the escapes +/// itself. Those two grids were the largest single claim on a 384 KiB heap and the reason the board +/// was held to 40x12 - the comment above `max_cols` used to say a cell was paid for four times over, +/// and this is what took it down to two. vaxis is still doing the work only it can do here: entering +/// the alternate screen, asking the terminal what it is, and parsing everything that comes back. +fn vaxisSize() vaxis.Winsize { + return if (direct_emit) + .{ .rows = 1, .cols = 1, .x_pixel = 0, .y_pixel = 0 } + else + cur_winsize; +} + +// -------------------------------------------------------------------------------------- exports + +/// 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_esp32p4_init( + alloc: *const Allocator, + write: WriteFn, + gpio: ?GpioFn, + ctx: ?*anyopaque, + cols: u16, + rows: u16, +) callconv(.c) u32 { + host_alloc = alloc.*; + out_write = write; + host_gpio = gpio; + out_ctx = ctx; + out = .{ .vtable = &.{ .drain = drain }, .buffer = &out_buf }; + + const a = gpa(); + env_map = .{ .array_hash_map = .empty, .allocator = a }; + // Clamped, so a firmware asking for more than the heap affords still starts. See `max_cols`. + cur_winsize = .{ + .rows = @min(rows, max_rows), + .cols = @min(cols, max_cols), + .x_pixel = 0, + .y_pixel = 0, + }; + + const allocs = pardes.allocators.init(a); + // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind + // the Host vtable, and the ones that are not are the ones this platform does not have. + pardes.image.start(std.Io.failing, allocs.image); + pardes.syntax.start(allocs.tree_sitter); + + vx = vaxis.init(std.Io.failing, a, &env_map, .{}) catch |err| return errCode(err); + vx.resize(a, &out, vaxisSize()) catch |err| return errCode(err); + + // Ask the terminal what it is. Both halves are pure byte writers, which is the whole reason this + // works over a serial line: the replies arrive as ordinary input and are parsed like any key. + vx.enterAltScreen(&out) catch |err| return errCode(err); + vx.queryTerminalSend(&out) catch |err| return errCode(err); + + // MOUSE REPORTING, spelled out here rather than taken from `vx.setMouseMode`. + // + // vaxis enables `1002;1003;1004;1006`, and 1003 is ANY-MOTION tracking: the terminal reports + // every cell the pointer crosses with no button held. On a 115200 line that is unaffordable - + // one sweep across this grid is dozens of reports of ~15 bytes each, and each one arrives as + // input that the editor must parse while it is trying to paint. Worse, it arrives whether or not + // anybody wants it, so moving the mouse over the window would starve typing. + // + // 1002 reports presses, releases and motion WHILE A BUTTON IS HELD, which is exactly the set a + // click and a drag-select need. 1004 is focus in/out, which `apply` already handles. 1006 is the + // SGR encoding: unlike the original X10 form it is not limited to column 223, which a grid this + // small does not need today but costs nothing to have and cannot be added later without the + // terminal disagreeing with the editor about where the pointer is. + out.writeAll("\x1b[?1002;1004;1006h") catch |err| return errCode(err); + out.flush() catch |err| return errCode(err); + + // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just + // sized to `cur_winsize`. + core = pardes.Pardes.init(allocs.pardes, .{ + .cols = cur_winsize.cols, + .rows = cur_winsize.rows, + .frame_allocator = allocs.frame, + .image_allocator = allocs.image, + .tree_sitter_allocator = allocs.tree_sitter, + }) catch |err| return errCode(err); + + dirty = true; + return 0; +} + +/// 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_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 + // malformed sequence, and keeping the tail is what lets it resynchronise. + const room = in_buf.len - in_len; + const take = @min(room, len); + if (take < len) { + in_len = 0; + @memcpy(in_buf[0..@min(len, in_buf.len)], ptr[0..@min(len, in_buf.len)]); + in_len = @min(len, in_buf.len); + } else { + @memcpy(in_buf[in_len..][0..take], ptr[0..take]); + in_len += take; + } + + drainInput(c, false); +} + +/// Parse what has accumulated, applying every event it yields. +/// +/// THE LONE ESCAPE IS AMBIGUOUS, and on this transport it is ambiguous constantly. `vaxis.Parser` +/// resolves a buffer containing nothing but `0x1b` as the Escape KEY - deliberately, and correctly +/// for a real terminal, where the kernel hands over a whole escape sequence in one read so a solitary +/// ESC really does mean the key. A 115200 serial line hands over one byte at a time: 87 us apart, +/// which is an eternity to this loop. So the first byte of EVERY escape sequence arrived alone and +/// was resolved as Escape, and the rest arrived as ordinary keys. +/// +/// That is not a mouse bug, though it is why the mouse did not work: a click report came through as +/// ten key presses - Escape, `[`, `<`, `0`, ... - and the `0` among them is "go to column zero" in +/// normal mode, which is exactly where the cursor kept landing. Arrow keys, function keys and the +/// host's in-band resize reports were all being shredded the same way. +/// +/// Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no final byte +/// 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_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; + while (off < in_len) { + if (!force and in_len - off == 1 and in_buf[off] == 0x1b) break; + const res = parser.parse(in_buf[off..in_len], gpa()) catch break; + if (res.n == 0) break; // incomplete: wait for more bytes + off += res.n; + if (res.event) |ev| apply(c, ev); + } + // Keep whatever was not consumed: the tail of a split escape sequence. + if (off > 0) { + std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]); + in_len -= off; + } + // Start or clear the hold. `esc_held_at` is only ever set for a buffer that is exactly one ESC, + // so a partial CSI - which the parser already declines - does not start a timer it does not need. + if (in_len == 1 and in_buf[0] == 0x1b) { + if (esc_held_at == null) esc_held_at = last_now_ms; + } else esc_held_at = null; +} + +/// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply` +/// (`src/tty/tty.zig:926-985`), minus everything that needs an OS. +fn apply(c: *pardes.Pardes, ev: vaxis.Event) void { + switch (ev) { + .key_press => |key| if (in_paste) { + // Between the brackets a key is DATA, never a command. vaxis gives control bytes no + // text at all, so a line break inside a paste arrives as a bare CR (Key.enter) or, from + // a terminal that does not translate them, as ctrl+j. + 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')) + "\n" + else + ""; + if (bytes.len > 0) paste_buf.appendSlice(gpa(), bytes) catch {}; + } else { + c.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }); + dirty = true; + }, + .paste_start => { + paste_buf.clearRetainingCapacity(); + in_paste = true; + }, + .paste_end => { + in_paste = false; + if (paste_buf.items.len > 0) { + c.update(.{ .paste = paste_buf.items }); + dirty = true; + } + paste_buf.clearRetainingCapacity(); + }, + // OSC 52. The bytes are the parser's, allocated from our own allocator, so they are freed + // here rather than leaked - the core copies whatever it keeps. + .paste => |text| { + c.update(.{ .paste = text }); + gpa().free(text); + dirty = true; + }, + .mouse => |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, + else => null, + }; + if (button) |b| { + c.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, + } }); + dirty = true; + } + }, + // The only way this platform learns its size, and the one place a 384 KiB heap shows through + // to the user. Two things happen here that the tty shell does not need. + // + // CLAMPED, because the grids do not fit an arbitrary terminal: vaxis keeps a `Screen` and an + // `InternalScreen`, pardes keeps its own `Surface` and `previous_cells`, so every cell is + // paid for four times. Measured on the die - 40x12 initialises with room to spare, 80x24 + // exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left. The host's + // terminal is normally larger than the board can render, so the editor takes a corner of it + // instead of refusing to start. + // + // ATOMIC, because `Vaxis.resize` deinits both screens BEFORE allocating the replacements + // (Vaxis.zig:194-206), so a failed resize leaves vaxis with freed screens and renders + // nothing at all. That is exactly how this was found: the host bridge injects a size report + // on attach, the 80x24 it reported could not be allocated, and an editor that had just drawn + // its interface went silent. A failure now puts the previous geometry back. + .winsize => |ws| { + const want: vaxis.Winsize = .{ + .rows = @min(ws.rows, max_rows), + .cols = @min(ws.cols, max_cols), + .x_pixel = ws.x_pixel, + .y_pixel = ws.y_pixel, + }; + if (want.cols == cur_winsize.cols and want.rows == cur_winsize.rows) return; + const previous = cur_winsize; + // vaxis is only resized when it is the thing doing the rendering. Under `direct_emit` its + // grids are a single cell and stay that way - see `vaxisSize` - so there is nothing here + // to reallocate, which also means a resize can no longer fail for want of two grids. + if (!direct_emit) { + vx.resize(gpa(), &out, want) catch { + vx.resize(gpa(), &out, previous) catch {}; + return; + }; + } + cur_winsize = want; + c.update(.{ .resize = .{ .cols = want.cols, .rows = want.rows } }); + dirty = true; + }, + // A TTY cannot report a pointer leaving its grid, so losing focus is the only reliable + // pointer-leave signal there is. + .focus_out => { + c.update(.pointer_leave); + dirty = true; + }, + .focus_in, .mouse_leave => {}, + // Capability replies. vaxis's own Loop sets these fields directly (`Loop.zig:377-403`); + // with no Loop, this is where they land. DA1 is the terminator: every terminal answers it + // last, so it is the signal that the whole handshake is in and the detected features can be + // switched on. + .cap_kitty_keyboard => vx.caps.kitty_keyboard = true, + .cap_kitty_graphics => vx.caps.kitty_graphics = true, + .cap_rgb => vx.caps.rgb = true, + .cap_unicode => { + vx.caps.unicode = .unicode; + vx.screen.width_method = .unicode; + }, + .cap_sgr_pixels => vx.caps.sgr_pixels = true, + .cap_color_scheme_updates => vx.caps.color_scheme_updates = true, + .cap_multi_cursor => vx.caps.multi_cursor = true, + .cap_da1 => { + vx.enableDetectedFeatures(&out) catch {}; + out.flush() catch {}; + dirty = true; + }, + .color_report, .color_scheme => {}, + .key_release => {}, + } +} + +/// The effective codepoint the way vaxis's own `Key.matches` sees it: a single-character `text` +/// wins, because the terminal has already resolved shift; otherwise the shifted codepoint. +fn effCp(key: vaxis.Key) u21 { + if (key.text) |t| { + const view = std.unicode.Utf8View.init(t) catch return key.codepoint; + var it = view.iterator(); + if (it.nextCodepoint()) |cp| { + if (it.nextCodepoint() == null) return cp; + } + } + return key.shifted_codepoint orelse key.codepoint; +} + +/// vaxis functional-key codepoints -> core constants. The ASCII ones already coincide, so +/// enter/tab/escape/backspace pass straight through. +fn mapKey(cp: u21) u21 { + return switch (cp) { + vaxis.Key.up => pardes.Key.up, + vaxis.Key.down => pardes.Key.down, + vaxis.Key.left => pardes.Key.left, + vaxis.Key.right => pardes.Key.right, + vaxis.Key.home => pardes.Key.home, + vaxis.Key.end => pardes.Key.end, + vaxis.Key.page_up => pardes.Key.page_up, + vaxis.Key.page_down => pardes.Key.page_down, + vaxis.Key.delete => pardes.Key.delete, + else => cp, + }; +} +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, + // not the head of a sequence: the next byte of a real sequence is 87 us behind on this line, and + // even a slow terminal emulator answers a query in well under a millisecond. Ten is generous by + // two orders of magnitude and imperceptible to the person pressing it - the same trade every + // terminal editor makes for the same reason. + if (esc_held_at) |at| { + if (now_ms -% at >= esc_hold_ms) { + drainInput(c, true); + dirty = true; + } + } + if (c.animationActive()) { + c.update(.tick); + dirty = true; + } +} + +/// 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_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_esp32p4_wants_frame() callconv(.c) bool { + const c = core orelse return false; + return dirty or c.animationActive(); +} + +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_esp32p4_quit() callconv(.c) bool { + const c = core orelse return true; + return c.quit; +} + +// ------------------------------------------------------------------------------------ the host + +const pardes_host: pardes.Host.VTable = .{ .push_present = present, .pull_gpio_toggle = gpioToggle }; + +/// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and +/// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message +/// row rather than a trap. +fn gpioToggle(_: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool { + const f = host_gpio orelse return false; + return f(out_ctx, pin, was, now); +} + +/// The canonical surface -> the wire. Same shape as the tty shell's (`src/tty/tty.zig:1096`) minus +/// the panel compositor and the kitty image path: neither has a reason to exist on a board with no +/// pixels. Where the tty shell hands every cell to vaxis and lets it diff, this diffs against the +/// Surface itself and can then emit the ANSI directly - see `direct_emit`. +fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { + const t0 = cycles(); + const win = vx.window(); + const n = @as(usize, surface.cols) * @as(usize, surface.rows); + + // THE SHADOW GRID. Copying all 480 cells into vaxis every frame cost 6.75 ms on the die - 57% + // of a keystroke, and it was paid whether or not anything changed: a second render with nothing + // new measured the same as the first. vaxis already diffs its own grid against the terminal, but + // it can only do that AFTER being told every cell, and being told is the expensive part + // (`writeCell` builds a vaxis `Cell`, which carries an always-null image placement). + // + // So keep the previous Surface and tell vaxis only what moved. `Cell.visuallyEqual` is the + // right comparison and already exists for the panel compositor's benefit: it ignores scratch + // bytes past `len` and treats any two default cells as equal, so it cannot manufacture a write. + // + // STATIC, and that is not a micro-optimisation - it is a bug fix. The first version allocated + // this from the editor's heap, and on a board whose 384 KiB is already nearly spoken for that + // was enough to make `vx.resize` fail: a resize then hit its OOM path, restored the previous + // geometry and returned, so the screen was never repainted. Measured as a resize emitting 80 + // bytes where it had emitted 1,392. The grid is bounded by `max_cols` x `max_rows` at comptime, + // so it belongs in `.bss` where it cannot compete with anything. + const full = !shadow_grid or prev_cols != surface.cols or prev_rows != surface.rows; + emit_bytes = 0; + if (full) { + prev_cols = surface.cols; + prev_rows = surface.rows; + if (direct_emit) { + // Reset first: a `2J` while a non-default background is active fills the screen with it. + emitRaw("\x1b[0m\x1b[2J") catch return; + emit_style = .{}; + emit_col = -1; + } else win.clear(); + } + const usable = shadow_grid and n <= prev_cells.len; + + var y: u16 = 0; + while (y < surface.rows) : (y += 1) { + const row0 = @as(usize, y) * @as(usize, surface.cols); + const src = surface.cells[row0..][0..surface.cols]; + + // A ROW AT A TIME FIRST. `Surface.cells` is contiguous and row-major, so a whole row is one + // `memcmp` against the shadow - and on a keystroke eleven of twelve rows are untouched. The + // per-cell loop below is ~40 branchy comparisons where this is one call over 1,120 bytes; + // measured, the walk fell from 246 us to a fraction of it. Byte equality implies visual + // equality (see `sameCell`), so a row that compares equal cannot be hiding a changed cell - + // and a row that differs only in padding falls through to the per-cell path, which is + // correct and merely slower. + if (usable and !full) { + const shadow = prev_cells[row0..][0..surface.cols]; + if (sameBytes(std.mem.sliceAsBytes(src), std.mem.sliceAsBytes(shadow))) continue; + } + + var x: u16 = 0; + while (x < surface.cols) : (x += 1) { + const cell = &src[x]; + const idx = row0 + @as(usize, x); + if (usable) { + if (!full and sameCell(cell, &prev_cells[idx])) continue; + prev_cells[idx] = cell.*; + } else if (cell.default) continue; + + writeOne(win, x, y, cell, surface.cols) catch return; + } + } + if (direct_emit) { + // BOTH branches have to reach the packet boundary, and the second one is easy to forget: + // measured, a frame that only hid the cursor was 6 bytes and cost 4014 us at 640 characters + // against 3863 at 320, because 6 bytes never fills a packet and waited out the bridge's + // timer. Hiding an already-hidden cursor is as idempotent as positioning it twice. + if (surface.cursor) |cur| { + cup(cur.y, cur.x) catch return; + emitRaw("\x1b[?25h") catch return; + emit_col = -1; + while (emit_bytes < emit_min_frame) cup(cur.y, cur.x) catch return; + } else { + while (emit_bytes < emit_min_frame) emitRaw("\x1b[?25l") catch return; + } + } else if (surface.cursor) |cur| { + win.showCursor(cur.x, cur.y); + } else win.hideCursor(); + const t1 = cycles(); + + // vaxis diffs against its own shadow grid, so this writes only what changed - which is what + // makes an editor usable at 11.9 KB/s. With `direct_emit` that diff has already happened, one + // stage earlier and against the Surface itself, so there is nothing left here to do. + if (!direct_emit) vx.render(&out) catch return; + const t2 = cycles(); + out.flush() catch return; + const t3 = cycles(); + + prof_copy_cy = t1 -% t0; + prof_render_cy = t2 -% t1; + prof_flush_cy = t3 -% t2; +} + +/// One cell to the wire, either through vaxis or straight out. +inline fn writeOne(win: vaxis.Window, x: u16, y: u16, cell: *const pardes.Cell, cols: u16) !void { + if (!direct_emit) { + // Changed TO default. `win.clear()` is what used to blank these, and it is not run on an + // incremental frame, so say it explicitly. + if (cell.default) return win.writeCell(x, y, .{ .char = .{ .grapheme = " " }, .style = .{} }); + return win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); + } + + if (emit_row != y or emit_col != x) { + try cup(y, x); + emit_row = y; + emit_col = @intCast(x); + } + + const style: pardes.CellStyle = if (cell.default) .{} else cell.style; + if (!std.meta.eql(emit_style, style)) { + try emitStyle(style); + emit_style = style; + } + + try emitRaw(if (cell.default) " " else cell.grapheme()); + + // Where the terminal's cursor now is. A single printable ASCII byte advanced it exactly one + // column; anything else - a wide glyph, a cluster, the spacer cell pardes writes after a wide + // one - is not worth predicting, so give up and let the next cell emit an absolute CUP. The last + // column is given up on too, because whether the cursor rests on it or has wrapped past it + // depends on the terminal's deferred-wrap behaviour, and the two disagree by a whole row. + if (x + 1 < cols and cell.len == 1 and cell.text[0] >= 0x20 and cell.text[0] < 0x7f) { + emit_col += 1; + } else emit_col = -1; +} + +/// A style as an absolute SGR, always opening with a reset. +/// +/// Absolute rather than a delta from whatever is currently on, and that is what keeps it short +/// enough to be worth having: no per-attribute off-codes, no state to keep beyond the last style +/// emitted, and a frame that gets cut off cannot leave a later cell wearing an earlier one's colour. +/// It costs a few bytes on a style change, against the ~9 of CUP a changed cell is paying anyway. +fn emitStyle(s: pardes.CellStyle) !void { + try emitRaw("\x1b[0"); + if (s.bold) try emitRaw(";1"); + if (s.dim) try emitRaw(";2"); + if (s.italic) try emitRaw(";3"); + if (s.blink) try emitRaw(";5"); + if (s.reverse) try emitRaw(";7"); + if (s.invisible) try emitRaw(";8"); + if (s.strikethrough) try emitRaw(";9"); + try emitRaw(switch (s.ul) { + .off => "", + .single => ";4", + .double => ";4:2", + .curly => ";4:3", + .dotted => ";4:4", + .dashed => ";4:5", + }); + try emitColor(s.fg, 30); + try emitColor(s.bg, 40); + try emitRaw("m"); +} + +/// `base` is 30 for a foreground and 40 for a background, which is the only thing separating the two +/// in every form SGR has for a colour: 30-37 against 40-47, 90-97 against 100-107, 38 against 48. +fn emitColor(c: pardes.Color, comptime base: u16) !void { + var b: [20]u8 = undefined; + var i: usize = 0; + switch (c) { + // Already said by the reset this SGR opens with. + .default => return, + .index => |n| { + b[i] = ';'; + i += 1; + if (n < 8) { + i += dec(b[i..], base + n); + } else if (n < 16) { + i += dec(b[i..], base + 60 + (n - 8)); + } else { + i += dec(b[i..], base + 8); + i += lit(b[i..], ";5;"); + i += dec(b[i..], n); + } + }, + .rgb => |v| { + b[i] = ';'; + i += 1; + i += dec(b[i..], base + 8); + i += lit(b[i..], ";2;"); + for (v, 0..) |component, k| { + if (k != 0) { + b[i] = ';'; + i += 1; + } + i += dec(b[i..], component); + } + }, + } + try emitRaw(b[0..i]); +} + +/// Absolute cursor positioning, hand-rolled rather than through `out.print`. +/// +/// Not for elegance: this is the single most frequent sequence the emitter produces, at least one per +/// changed run, and `std.fmt` brings a whole format-string interpreter to write at most two digits. +/// The grid is bounded by `max_cols` x `max_rows`, so nothing here can exceed three. +fn cup(row: u16, col: u16) !void { + var b: [12]u8 = undefined; + var i: usize = lit(&b, "\x1b["); + i += dec(b[i..], row + 1); + b[i] = ';'; + i += 1; + i += dec(b[i..], col + 1); + b[i] = 'H'; + i += 1; + try emitRaw(b[0..i]); +} + +/// Decimal, least significant digit first into a scratch buffer and then reversed. Five digits is +/// every `u16`, so there is no fallback to `std.fmt` and no value this cannot write. +fn dec(buf: []u8, v: u16) usize { + var digits: [5]u8 = undefined; + var n: usize = 0; + var rest = v; + while (true) { + digits[n] = '0' + @as(u8, @intCast(rest % 10)); + n += 1; + rest /= 10; + if (rest == 0) break; + } + for (0..n) |k| buf[k] = digits[n - 1 - k]; + return n; +} + +inline fn lit(buf: []u8, comptime s: []const u8) usize { + @memcpy(buf[0..s.len], s); + return s.len; +} + +/// Every direct-emit byte goes through here, because the count is what the padding below needs. +inline fn emitRaw(bytes: []const u8) !void { + emit_bytes += bytes.len; + try out.writeAll(bytes); +} + +/// A/B switch for the emitter above, on the same terms as `shadow_grid`: false routes every cell back +/// through vaxis, which is the reference. vaxis's own diff measured 631 us of a 4.37 ms keystroke and +/// all of it was redundant - `present` has already worked out which cells moved, so vaxis was being +/// told the answer and then computing it again from scratch. +const direct_emit = true; + +/// THE FRAME HAS A MINIMUM SIZE, and it is the USB bridge's, not the terminal's. +/// +/// The board is wired to the host through a CH340, and 32 is not a guess: it is `wMaxPacketSize` of +/// endpoint 0x82, the bulk IN, as the device itself reports it - a full-speed 0x0020. The bridge +/// forwards a packet when the packet is FULL, so a frame shorter than that sits there until an +/// internal timer gives up on more, which is worth about a millisecond - a quarter of the budget. +/// +/// Measured, at the same board cost and with the screen byte-identical: a 21-byte frame round-trips +/// in 4817 us and the same frame padded to 49 bytes in 3814 us. MORE BYTES, ARRIVING SOONER. It also +/// explains why routing through vaxis looked competitive - its frames are 81 bytes, so they fill a +/// packet by accident and never wait. +/// +/// So pad to the packet boundary. The filler is repeated absolute cursor positioning: idempotent, +/// already the sequence the emitter ends on, and it cannot alter a cell. This is the same bargain as +/// an Ethernet runt frame - the medium has a minimum and the sender pays it - and it is a real +/// trade, not free: the wasted bytes are wire time that delays a LATER frame, so it is only worth it +/// while the frame is small, which is exactly when it applies. +const emit_min_frame: usize = 32; + +/// Bytes emitted this frame, for `emit_min_frame`. +var emit_bytes: usize = 0; + +/// What the terminal is currently wearing and where its cursor is, so that a run of changed cells in +/// one row costs one CUP and one SGR rather than one of each per cell. `emit_col` is signed because +/// -1 means "no longer known" - see `writeOne`. +var emit_style: pardes.CellStyle = .{}; +var emit_row: u16 = 0; +var emit_col: i32 = -1; + +/// A/B switch, kept because this optimisation is exactly the kind that can be right about latency +/// and wrong about the screen. With it false, `present` behaves as it did before the shadow grid - +/// clear and write every cell - which is the reference any measurement of it should be compared +/// against, and the way to tell a rendering bug from a rendering difference. +const shadow_grid = true; + +/// The previous Surface, cell for cell, sized for the largest grid this board can drive. In `.bss` +/// rather than on the heap: see `present`. `prev_cols`/`prev_rows` being zero on the first frame is +/// what makes that frame a full one. +var prev_cells: [@as(usize, max_cols) * @as(usize, max_rows)]pardes.Cell = if (shadow_grid) @splat(.{}) else undefined; +var prev_cols: u16 = 0; +var prev_rows: u16 = 0; + +/// Cell equality for the shadow grid, as bytes. +/// +/// `Cell.visuallyEqual` is the semantically exact answer and it is too slow to ask 480 times a +/// frame: `std.meta.eql` on a `CellStyle` recurses through a colour union and eight booleans, and +/// the walk measured 1.45 ms - about 270 cycles per comparison of a ~28-byte struct. +/// +/// Byte equality IMPLIES visual equality, so this can never claim two different cells are the same. +/// It can miss an equality - scratch bytes past `len`, or padding - and the only cost of that is one +/// redundant `writeCell` that vaxis then diffs away. Defaults are still handled by meaning rather +/// than by bytes, because an unpainted cell's text and style are whatever the last frame left there. +inline fn sameCell(a: *const pardes.Cell, b: *const pardes.Cell) bool { + if (a.default or b.default) return a.default and b.default; + return sameBytes(std.mem.asBytes(a), std.mem.asBytes(b)); +} + +/// Exact byte equality, a word at a time when both spans are aligned for it. +/// +/// This comparison is the firmware's largest read by a wide margin - two 13 KB streams every frame - +/// and it measured 3.2 cycles per byte, about four times what word-wide loads should need, which is +/// what a byte-at-a-time loop looks like. The answer is identical either way: this is still exact +/// byte equality, so it keeps the property the whole diff rests on, that byte equality implies +/// visual equality. +/// +/// The alignment test is a RUNTIME one because `Cell` has alignment 1 - it is all `u8` fields - so +/// whether a row begins on a word boundary is a property of whoever allocated the Surface and not of +/// the type. A row is 40 cells of 26 bytes, which is divisible by four, so if the base is aligned +/// every row is. When it is not, the byte loop is still here. +inline fn sameBytes(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); +} + +// ------------------------------------------------------------------ where a frame's time goes +// +// A frame has three stages and they want different fixes, so the firmware is given all three rather +// than one total. Measured on the die, a render costs ~11 ms whether or not anything changed, which +// says the cost is the unconditional walk and not the edit - but "the walk" is two walks, the copy +// into vaxis's grid and vaxis's own diff, and only one of them is ours to change. +// +// Two CSR reads per stage. `cycle` is the unprivileged counter, read high-low-high because two +// 32-bit halves can straddle a wrap. +var prof_copy_cy: u64 = 0; +var prof_render_cy: u64 = 0; +var prof_flush_cy: u64 = 0; + +inline fn cycles() u64 { + if (builtin.cpu.arch != .riscv32) return 0; + while (true) { + const hi0 = asm volatile ("csrr %[o], cycleh" + : [o] "=r" (-> u32), + ); + const lo = asm volatile ("csrr %[o], cycle" + : [o] "=r" (-> u32), + ); + const hi1 = asm volatile ("csrr %[o], cycleh" + : [o] "=r" (-> u32), + ); + if (hi0 == hi1) return (@as(u64, hi0) << 32) | lo; + } +} + +/// The last frame's three stages, in cycles. Zero on any platform without the CSR. +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; +} + +fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { + return .{ + .fg = vaxisColor(s.fg), + .bg = vaxisColor(s.bg), + .bold = s.bold, + .dim = s.dim, + .italic = s.italic, + .blink = s.blink, + .reverse = s.reverse, + .invisible = s.invisible, + .strikethrough = s.strikethrough, + .ul_style = switch (s.ul) { + .off => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; +} + +fn vaxisColor(c: pardes.Color) vaxis.Color { + return switch (c) { + .default => .default, + .index => |i| .{ .index = i }, + .rgb => |rgb| .{ .rgb = rgb }, + }; +} + +/// Errors cross the ABI as small non-zero integers. `@intFromError` is not stable across builds, so +/// it is not used: the firmware only reports the number, and a stable-looking value that silently +/// changed meaning would be worse than an opaque one. +fn errCode(err: anyerror) u32 { + return switch (err) { + error.OutOfMemory => 1, + error.WriteFailed => 2, + else => 255, + }; +} 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(©_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= ...at 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= ...named 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= ...of the session called , 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[=]`: 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[=]`, 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=` and never `--detach `, 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=` and never `--attach `, 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-.sock` +/// rather than `pardes-.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//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/p4.zig b/src/p4.zig deleted file mode 100644 index 8e46b2ff..00000000 --- a/src/p4.zig +++ /dev/null @@ -1,1030 +0,0 @@ -//! 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 -//! 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. -//! -//! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this -//! package in its `build.zig.zon` and import `pardes_p4`. That was built, and it broke every build -//! in that repo: nesting this package's ~30-package graph under one whose own claim is "host -//! dependencies: Zig, that is the whole list" made `std/Build.zig:2091` exceed its 1000-branch -//! comptime quota (through ghostty's `SharedDeps.zig:874` `lazyImport`), dragged in seven cached -//! tree-sitter versions whose `build.zig` uses APIs removed in 0.16, and materialised 2.6 GB across -//! 42,736 files into that repo's working copy. A linked object has none of those properties and one -//! extra virtue: the seam is bytes, so neither side can accidentally depend on the other's types. -//! -//! **Where the terminal is.** On the host. The board writes ANSI and reads ANSI; the terminal -//! emulator at the far end of the serial line does the font rendering, and answers this program's -//! own capability queries. That is why `vaxis` works here unmodified: `Vaxis.render`, -//! `queryTerminalSend` and `enableDetectedFeatures` all take a bare `*std.Io.Writer` -//! (`Vaxis.zig:375,278,329`), so the transport is a parameter. `vaxis.Tty` and `vaxis.Loop` are -//! termios/ioctl/SIGWINCH bound and are not used. -//! -//! **Where the memory is.** Not here either. The firmware measured its own RAM (240 KiB low, -//! 384 KiB high, and a 128 KiB region that turned out to be L2 cache) and owns the allocator; this -//! file receives four function pointers and rebuilds a `std.mem.Allocator` from them. Everything -//! the editor allocates comes from there. -//! -//! **Window size** arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like -//! any other input. Firmware has no `TIOCGWINSZ`, so the host-side bridge synthesises the first one. - -const std = @import("std"); -const builtin = @import("builtin"); -const pardes = @import("pardes.zig"); -const vaxis = @import("vaxis"); - -// ------------------------------------------------------------------ what a freestanding root owes -// -// These are ROOT-module declarations: std reads them off whichever file is the compilation root, and -// as of the build change that emits this file as the object, that is this file. They are not -// ceremony - each one was discovered by the build failing without it. - -/// The board has no MMU and no pages, but std derives allocator alignment from these two. 4 KiB is -/// the ESP32-P4's cache and DMA granularity. Without them: "riscv32-freestanding has unknown -/// page_size_min" from std/heap.zig:48. -/// -/// `logFn` is the load-bearing one. 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 on this target, and ONE `log.warn` anywhere in the core or in vaxis is enough to drag the -/// whole thing in and fail the build with "no member named 'getrandom'". -pub const std_options: std.Options = .{ - .page_size_min = 4096, - .page_size_max = 4096, - .logFn = logFn, -}; - -/// Logs go out the same byte sink as the frames, which is the only sink there is. Truncated rather -/// than allocated: a log line is never worth an allocation on a 384 KiB heap, and a logger that can -/// fail on OOM is a logger that disappears exactly when it is needed. -fn logFn( - comptime level: std.log.Level, - comptime scope: @EnumLiteral(), - comptime fmt: []const u8, - args: anytype, -) void { - if (out_ctx == null and @intFromPtr(out_write) == 0) return; - 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 truncated]\r\n"; - out_write(out_ctx, line.ptr, line.len); -} - -pub const panic = std.debug.FullPanic(panicImpl); - -/// A panic here cannot unwind and has nowhere to go, so it reports through the write callback and -/// stops. `@trap` and not a spin: the firmware's own panic handler prints through the mask ROM, -/// which shares nothing with this path but the FIFO, so a trap leaves that diagnostic route intact. -fn panicImpl(msg: []const u8, _: ?usize) noreturn { - const prefix = "\r\nMARK PARDES_CORE_PANIC "; - out_write(out_ctx, prefix.ptr, prefix.len); - out_write(out_ctx, msg.ptr, msg.len); - out_write(out_ctx, "\r\n", 2); - @trap(); -} - -// ---------------------------------------------------------------------------------- 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 -// 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 -/// 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 { - return abi_version; -} - -/// The firmware's allocator, as C function pointers. `alignment` is a log2 value, matching -/// `std.mem.Alignment`'s own representation, so no translation table is needed. -/// -/// `remap` is absent on purpose: this allocator cannot move a block without copying it, so -/// `std.mem.Allocator`'s remap is implemented locally as "resize in place, or fail" and the caller's -/// own alloc/copy/free path handles the rest. -pub 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, -}; - -/// How finished runs of ANSI leave this object. -pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void; - -/// Flip one pad and report the level before and after; false if the firmware declines. OPTIONAL on -/// the wire, so a host with no pads (or one that has not implemented them yet) passes null and the -/// `Gpio` word answers "no pads" instead of the object having to know which firmwares exist. -/// -/// The board's side, not the editor's, because a correct toggle is the IO MUX, the GPIO matrix, the -/// pad's own bits and the output enable - four register files behind a per-pin table that the -/// firmware already has and checks against ESP-IDF. See `Host.VTable.pull_gpio_toggle`. -pub const GpioFn = *const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool; - -// ------------------------------------------------------------------- the allocator, rebuilt -// One `std.mem.Allocator` whose vtable forwards to the four pointers above. The indirection is the -// price of the seam and it is paid once per allocation, which on a first-fit heap is already the -// cheap part (measured on the die: 8,229 cycles for one allocation across 257 free blocks). - -var host_alloc: Allocator = undefined; - -fn hostAlloc(_: *anyopaque, len: usize, alignment: std.mem.Alignment, _: usize) ?[*]u8 { - return host_alloc.alloc(host_alloc.ctx, len, @intFromEnum(alignment)); -} - -fn hostResize(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) bool { - return host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len); -} - -fn hostRemap(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, new_len: usize, _: usize) ?[*]u8 { - return if (host_alloc.resize(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment), new_len)) mem.ptr else null; -} - -fn hostFree(_: *anyopaque, mem: []u8, alignment: std.mem.Alignment, _: usize) void { - host_alloc.free(host_alloc.ctx, mem.ptr, mem.len, @intFromEnum(alignment)); -} - -const host_vtable: std.mem.Allocator.VTable = .{ - .alloc = hostAlloc, - .resize = hostResize, - .remap = hostRemap, - .free = hostFree, -}; - -/// `ptr` is never dereferenced - the four forwarders read the file-scope `host_alloc` - but -/// `std.mem.Allocator` requires a non-null context, so it points at the record itself. -fn gpa() std.mem.Allocator { - return .{ .ptr = @ptrCast(&host_alloc), .vtable = &host_vtable }; -} - -// ------------------------------------------------------------------------------- the ANSI sink -// A `std.Io.Writer` over the firmware's write callback. Buffered, because vaxis emits a frame as a -// long run of small writes - cursor move, SGR run, grapheme, repeat - and an unbuffered writer would -// make a C call per fragment. - -var out_write: WriteFn = undefined; -var out_ctx: ?*anyopaque = null; -var host_gpio: ?GpioFn = null; -var out_buf: [8192]u8 = undefined; -var out: std.Io.Writer = undefined; - -fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { - // The shape std documents at Io/Writer.zig:46-63: buffer first, then every slice of `data`, with - // the LAST slice repeated `splat` times, and the count returned excluding the buffered bytes. - if (w.end > 0) { - out_write(out_ctx, w.buffer.ptr, w.end); - w.end = 0; - } - const head = data[0 .. data.len - 1]; - const pattern = data[head.len]; - var written: usize = 0; - for (head) |bytes| { - if (bytes.len > 0) out_write(out_ctx, bytes.ptr, bytes.len); - written += bytes.len; - } - var i: usize = 0; - while (i < splat) : (i += 1) { - if (pattern.len > 0) out_write(out_ctx, pattern.ptr, pattern.len); - } - return written + pattern.len * splat; -} - -// ------------------------------------------------------------------------------------ the state - -var core: ?*pardes.Pardes = null; -var vx: vaxis.Vaxis = undefined; -var parser: vaxis.Parser = .{}; - -/// vaxis wants an environment map. There is no environment; an empty one is the honest answer and -/// the only thing vaxis reads it for is TERM-derived heuristics, which the capability queries -/// supersede. -var env_map: std.process.Environ.Map = undefined; - -/// Input that arrived mid-sequence. An escape sequence can be split across UART reads, and the -/// parser reports "incomplete" by consuming nothing, so the tail has to survive until more arrives. -var in_buf: [1024]u8 = undefined; -var in_len: usize = 0; - -/// Bracketed paste: between the markers, keys are DATA and never commands. -var paste_buf: std.ArrayListUnmanaged(u8) = .empty; -var in_paste: bool = false; - -/// Set by anything that could change the screen; cleared by a render. The firmware asks before -/// rendering, because on a 115200-baud link an unconditional repaint per loop saturates the wire and -/// starves input. -var dirty: bool = true; - -/// The largest grid this board can render, and the reason it is not just the host's terminal size. -/// -/// A cell is paid for TWICE now, not four times: pardes keeps its `Surface` and this shell keeps a -/// shadow copy of it to diff against. vaxis used to keep a `Screen` and an `InternalScreen` as well, -/// 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 -/// 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; - -var cur_winsize: vaxis.Winsize = .{ .rows = max_rows, .cols = max_cols, .x_pixel = 0, .y_pixel = 0 }; - -/// How big vaxis's own grids need to be. -/// -/// ONE CELL under `direct_emit`, because neither of them is ever read: vaxis keeps a `Screen` and an -/// `InternalScreen`, and the emitter diffs the Surface against its own shadow and writes the escapes -/// itself. Those two grids were the largest single claim on a 384 KiB heap and the reason the board -/// was held to 40x12 - the comment above `max_cols` used to say a cell was paid for four times over, -/// and this is what took it down to two. vaxis is still doing the work only it can do here: entering -/// the alternate screen, asking the terminal what it is, and parsing everything that comes back. -fn vaxisSize() vaxis.Winsize { - return if (direct_emit) - .{ .rows = 1, .cols = 1, .x_pixel = 0, .y_pixel = 0 } - else - cur_winsize; -} - -// -------------------------------------------------------------------------------------- exports - -/// 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( - alloc: *const Allocator, - write: WriteFn, - gpio: ?GpioFn, - ctx: ?*anyopaque, - cols: u16, - rows: u16, -) callconv(.c) u32 { - host_alloc = alloc.*; - out_write = write; - host_gpio = gpio; - out_ctx = ctx; - out = .{ .vtable = &.{ .drain = drain }, .buffer = &out_buf }; - - const a = gpa(); - env_map = .{ .array_hash_map = .empty, .allocator = a }; - // Clamped, so a firmware asking for more than the heap affords still starts. See `max_cols`. - cur_winsize = .{ - .rows = @min(rows, max_rows), - .cols = @min(cols, max_cols), - .x_pixel = 0, - .y_pixel = 0, - }; - - const allocs = pardes.allocators.init(a); - // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind - // the Host vtable, and the ones that are not are the ones this platform does not have. - pardes.image.start(std.Io.failing, allocs.image); - pardes.syntax.start(allocs.tree_sitter); - - vx = vaxis.init(std.Io.failing, a, &env_map, .{}) catch |err| return errCode(err); - vx.resize(a, &out, vaxisSize()) catch |err| return errCode(err); - - // Ask the terminal what it is. Both halves are pure byte writers, which is the whole reason this - // works over a serial line: the replies arrive as ordinary input and are parsed like any key. - vx.enterAltScreen(&out) catch |err| return errCode(err); - vx.queryTerminalSend(&out) catch |err| return errCode(err); - - // MOUSE REPORTING, spelled out here rather than taken from `vx.setMouseMode`. - // - // vaxis enables `1002;1003;1004;1006`, and 1003 is ANY-MOTION tracking: the terminal reports - // every cell the pointer crosses with no button held. On a 115200 line that is unaffordable - - // one sweep across this grid is dozens of reports of ~15 bytes each, and each one arrives as - // input that the editor must parse while it is trying to paint. Worse, it arrives whether or not - // anybody wants it, so moving the mouse over the window would starve typing. - // - // 1002 reports presses, releases and motion WHILE A BUTTON IS HELD, which is exactly the set a - // click and a drag-select need. 1004 is focus in/out, which `apply` already handles. 1006 is the - // SGR encoding: unlike the original X10 form it is not limited to column 223, which a grid this - // small does not need today but costs nothing to have and cannot be added later without the - // terminal disagreeing with the editor about where the pointer is. - out.writeAll("\x1b[?1002;1004;1006h") catch |err| return errCode(err); - out.flush() catch |err| return errCode(err); - - // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just - // sized to `cur_winsize`. - core = pardes.Pardes.init(allocs.pardes, .{ - .cols = cur_winsize.cols, - .rows = cur_winsize.rows, - .frame_allocator = allocs.frame, - .image_allocator = allocs.image, - .tree_sitter_allocator = allocs.tree_sitter, - }) catch |err| return errCode(err); - - dirty = true; - return 0; -} - -/// 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 { - const c = core orelse return; - - // Append, dropping the oldest on overflow: a full buffer means the parser is stuck on a - // malformed sequence, and keeping the tail is what lets it resynchronise. - const room = in_buf.len - in_len; - const take = @min(room, len); - if (take < len) { - in_len = 0; - @memcpy(in_buf[0..@min(len, in_buf.len)], ptr[0..@min(len, in_buf.len)]); - in_len = @min(len, in_buf.len); - } else { - @memcpy(in_buf[in_len..][0..take], ptr[0..take]); - in_len += take; - } - - drainInput(c, false); -} - -/// Parse what has accumulated, applying every event it yields. -/// -/// THE LONE ESCAPE IS AMBIGUOUS, and on this transport it is ambiguous constantly. `vaxis.Parser` -/// resolves a buffer containing nothing but `0x1b` as the Escape KEY - deliberately, and correctly -/// for a real terminal, where the kernel hands over a whole escape sequence in one read so a solitary -/// ESC really does mean the key. A 115200 serial line hands over one byte at a time: 87 us apart, -/// which is an eternity to this loop. So the first byte of EVERY escape sequence arrived alone and -/// was resolved as Escape, and the rest arrived as ordinary keys. -/// -/// That is not a mouse bug, though it is why the mouse did not work: a click report came through as -/// ten key presses - Escape, `[`, `<`, `0`, ... - and the `0` among them is "go to column zero" in -/// normal mode, which is exactly where the cursor kept landing. Arrow keys, function keys and the -/// host's in-band resize reports were all being shredded the same way. -/// -/// Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no final byte -/// 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 -/// 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; - while (off < in_len) { - if (!force and in_len - off == 1 and in_buf[off] == 0x1b) break; - const res = parser.parse(in_buf[off..in_len], gpa()) catch break; - if (res.n == 0) break; // incomplete: wait for more bytes - off += res.n; - if (res.event) |ev| apply(c, ev); - } - // Keep whatever was not consumed: the tail of a split escape sequence. - if (off > 0) { - std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]); - in_len -= off; - } - // Start or clear the hold. `esc_held_at` is only ever set for a buffer that is exactly one ESC, - // so a partial CSI - which the parser already declines - does not start a timer it does not need. - if (in_len == 1 and in_buf[0] == 0x1b) { - if (esc_held_at == null) esc_held_at = last_now_ms; - } else esc_held_at = null; -} - -/// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply` -/// (`src/tty/tty.zig:926-985`), minus everything that needs an OS. -fn apply(c: *pardes.Pardes, ev: vaxis.Event) void { - switch (ev) { - .key_press => |key| if (in_paste) { - // Between the brackets a key is DATA, never a command. vaxis gives control bytes no - // text at all, so a line break inside a paste arrives as a bare CR (Key.enter) or, from - // a terminal that does not translate them, as ctrl+j. - 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')) - "\n" - else - ""; - if (bytes.len > 0) paste_buf.appendSlice(gpa(), bytes) catch {}; - } else { - c.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = key.text orelse "", - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - .shift = key.mods.shift, - } }); - dirty = true; - }, - .paste_start => { - paste_buf.clearRetainingCapacity(); - in_paste = true; - }, - .paste_end => { - in_paste = false; - if (paste_buf.items.len > 0) { - c.update(.{ .paste = paste_buf.items }); - dirty = true; - } - paste_buf.clearRetainingCapacity(); - }, - // OSC 52. The bytes are the parser's, allocated from our own allocator, so they are freed - // here rather than leaked - the core copies whatever it keeps. - .paste => |text| { - c.update(.{ .paste = text }); - gpa().free(text); - dirty = true; - }, - .mouse => |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, - else => null, - }; - if (button) |b| { - c.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, - } }); - dirty = true; - } - }, - // The only way this platform learns its size, and the one place a 384 KiB heap shows through - // to the user. Two things happen here that the tty shell does not need. - // - // CLAMPED, because the grids do not fit an arbitrary terminal: vaxis keeps a `Screen` and an - // `InternalScreen`, pardes keeps its own `Surface` and `previous_cells`, so every cell is - // paid for four times. Measured on the die - 40x12 initialises with room to spare, 80x24 - // exhausts the heap and `Pardes.init` returns OutOfMemory with 9,128 bytes left. The host's - // terminal is normally larger than the board can render, so the editor takes a corner of it - // instead of refusing to start. - // - // ATOMIC, because `Vaxis.resize` deinits both screens BEFORE allocating the replacements - // (Vaxis.zig:194-206), so a failed resize leaves vaxis with freed screens and renders - // nothing at all. That is exactly how this was found: the host bridge injects a size report - // on attach, the 80x24 it reported could not be allocated, and an editor that had just drawn - // its interface went silent. A failure now puts the previous geometry back. - .winsize => |ws| { - const want: vaxis.Winsize = .{ - .rows = @min(ws.rows, max_rows), - .cols = @min(ws.cols, max_cols), - .x_pixel = ws.x_pixel, - .y_pixel = ws.y_pixel, - }; - if (want.cols == cur_winsize.cols and want.rows == cur_winsize.rows) return; - const previous = cur_winsize; - // vaxis is only resized when it is the thing doing the rendering. Under `direct_emit` its - // grids are a single cell and stay that way - see `vaxisSize` - so there is nothing here - // to reallocate, which also means a resize can no longer fail for want of two grids. - if (!direct_emit) { - vx.resize(gpa(), &out, want) catch { - vx.resize(gpa(), &out, previous) catch {}; - return; - }; - } - cur_winsize = want; - c.update(.{ .resize = .{ .cols = want.cols, .rows = want.rows } }); - dirty = true; - }, - // A TTY cannot report a pointer leaving its grid, so losing focus is the only reliable - // pointer-leave signal there is. - .focus_out => { - c.update(.pointer_leave); - dirty = true; - }, - .focus_in, .mouse_leave => {}, - // Capability replies. vaxis's own Loop sets these fields directly (`Loop.zig:377-403`); - // with no Loop, this is where they land. DA1 is the terminator: every terminal answers it - // last, so it is the signal that the whole handshake is in and the detected features can be - // switched on. - .cap_kitty_keyboard => vx.caps.kitty_keyboard = true, - .cap_kitty_graphics => vx.caps.kitty_graphics = true, - .cap_rgb => vx.caps.rgb = true, - .cap_unicode => { - vx.caps.unicode = .unicode; - vx.screen.width_method = .unicode; - }, - .cap_sgr_pixels => vx.caps.sgr_pixels = true, - .cap_color_scheme_updates => vx.caps.color_scheme_updates = true, - .cap_multi_cursor => vx.caps.multi_cursor = true, - .cap_da1 => { - vx.enableDetectedFeatures(&out) catch {}; - out.flush() catch {}; - dirty = true; - }, - .color_report, .color_scheme => {}, - .key_release => {}, - } -} - -/// The effective codepoint the way vaxis's own `Key.matches` sees it: a single-character `text` -/// wins, because the terminal has already resolved shift; otherwise the shifted codepoint. -fn effCp(key: vaxis.Key) u21 { - if (key.text) |t| { - const view = std.unicode.Utf8View.init(t) catch return key.codepoint; - var it = view.iterator(); - if (it.nextCodepoint()) |cp| { - if (it.nextCodepoint() == null) return cp; - } - } - return key.shifted_codepoint orelse key.codepoint; -} - -/// vaxis functional-key codepoints -> core constants. The ASCII ones already coincide, so -/// enter/tab/escape/backspace pass straight through. -fn mapKey(cp: u21) u21 { - return switch (cp) { - vaxis.Key.up => pardes.Key.up, - vaxis.Key.down => pardes.Key.down, - vaxis.Key.left => pardes.Key.left, - vaxis.Key.right => pardes.Key.right, - vaxis.Key.home => pardes.Key.home, - vaxis.Key.end => pardes.Key.end, - vaxis.Key.page_up => pardes.Key.page_up, - vaxis.Key.page_down => pardes.Key.page_down, - vaxis.Key.delete => pardes.Key.delete, - else => cp, - }; -} -export fn pardes_p4_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, - // not the head of a sequence: the next byte of a real sequence is 87 us behind on this line, and - // even a slow terminal emulator answers a query in well under a millisecond. Ten is generous by - // two orders of magnitude and imperceptible to the person pressing it - the same trade every - // terminal editor makes for the same reason. - if (esc_held_at) |at| { - if (now_ms -% at >= esc_hold_ms) { - drainInput(c, true); - dirty = true; - } - } - if (c.animationActive()) { - c.update(.tick); - dirty = true; - } -} - -/// 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 -/// 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 { - const c = core orelse return false; - return dirty or c.animationActive(); -} - -export fn pardes_p4_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 { - const c = core orelse return true; - return c.quit; -} - -// ------------------------------------------------------------------------------------ the host - -const pardes_host: pardes.Host.VTable = .{ .push_present = present, .pull_gpio_toggle = gpioToggle }; - -/// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and -/// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message -/// row rather than a trap. -fn gpioToggle(_: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool { - const f = host_gpio orelse return false; - return f(out_ctx, pin, was, now); -} - -/// The canonical surface -> the wire. Same shape as the tty shell's (`src/tty/tty.zig:1096`) minus -/// the panel compositor and the kitty image path: neither has a reason to exist on a board with no -/// pixels. Where the tty shell hands every cell to vaxis and lets it diff, this diffs against the -/// Surface itself and can then emit the ANSI directly - see `direct_emit`. -fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { - const t0 = cycles(); - const win = vx.window(); - const n = @as(usize, surface.cols) * @as(usize, surface.rows); - - // THE SHADOW GRID. Copying all 480 cells into vaxis every frame cost 6.75 ms on the die - 57% - // of a keystroke, and it was paid whether or not anything changed: a second render with nothing - // new measured the same as the first. vaxis already diffs its own grid against the terminal, but - // it can only do that AFTER being told every cell, and being told is the expensive part - // (`writeCell` builds a vaxis `Cell`, which carries an always-null image placement). - // - // So keep the previous Surface and tell vaxis only what moved. `Cell.visuallyEqual` is the - // right comparison and already exists for the panel compositor's benefit: it ignores scratch - // bytes past `len` and treats any two default cells as equal, so it cannot manufacture a write. - // - // STATIC, and that is not a micro-optimisation - it is a bug fix. The first version allocated - // this from the editor's heap, and on a board whose 384 KiB is already nearly spoken for that - // was enough to make `vx.resize` fail: a resize then hit its OOM path, restored the previous - // geometry and returned, so the screen was never repainted. Measured as a resize emitting 80 - // bytes where it had emitted 1,392. The grid is bounded by `max_cols` x `max_rows` at comptime, - // so it belongs in `.bss` where it cannot compete with anything. - const full = !shadow_grid or prev_cols != surface.cols or prev_rows != surface.rows; - emit_bytes = 0; - if (full) { - prev_cols = surface.cols; - prev_rows = surface.rows; - if (direct_emit) { - // Reset first: a `2J` while a non-default background is active fills the screen with it. - emitRaw("\x1b[0m\x1b[2J") catch return; - emit_style = .{}; - emit_col = -1; - } else win.clear(); - } - const usable = shadow_grid and n <= prev_cells.len; - - var y: u16 = 0; - while (y < surface.rows) : (y += 1) { - const row0 = @as(usize, y) * @as(usize, surface.cols); - const src = surface.cells[row0..][0..surface.cols]; - - // A ROW AT A TIME FIRST. `Surface.cells` is contiguous and row-major, so a whole row is one - // `memcmp` against the shadow - and on a keystroke eleven of twelve rows are untouched. The - // per-cell loop below is ~40 branchy comparisons where this is one call over 1,120 bytes; - // measured, the walk fell from 246 us to a fraction of it. Byte equality implies visual - // equality (see `sameCell`), so a row that compares equal cannot be hiding a changed cell - - // and a row that differs only in padding falls through to the per-cell path, which is - // correct and merely slower. - if (usable and !full) { - const shadow = prev_cells[row0..][0..surface.cols]; - if (sameBytes(std.mem.sliceAsBytes(src), std.mem.sliceAsBytes(shadow))) continue; - } - - var x: u16 = 0; - while (x < surface.cols) : (x += 1) { - const cell = &src[x]; - const idx = row0 + @as(usize, x); - if (usable) { - if (!full and sameCell(cell, &prev_cells[idx])) continue; - prev_cells[idx] = cell.*; - } else if (cell.default) continue; - - writeOne(win, x, y, cell, surface.cols) catch return; - } - } - if (direct_emit) { - // BOTH branches have to reach the packet boundary, and the second one is easy to forget: - // measured, a frame that only hid the cursor was 6 bytes and cost 4014 us at 640 characters - // against 3863 at 320, because 6 bytes never fills a packet and waited out the bridge's - // timer. Hiding an already-hidden cursor is as idempotent as positioning it twice. - if (surface.cursor) |cur| { - cup(cur.y, cur.x) catch return; - emitRaw("\x1b[?25h") catch return; - emit_col = -1; - while (emit_bytes < emit_min_frame) cup(cur.y, cur.x) catch return; - } else { - while (emit_bytes < emit_min_frame) emitRaw("\x1b[?25l") catch return; - } - } else if (surface.cursor) |cur| { - win.showCursor(cur.x, cur.y); - } else win.hideCursor(); - const t1 = cycles(); - - // vaxis diffs against its own shadow grid, so this writes only what changed - which is what - // makes an editor usable at 11.9 KB/s. With `direct_emit` that diff has already happened, one - // stage earlier and against the Surface itself, so there is nothing left here to do. - if (!direct_emit) vx.render(&out) catch return; - const t2 = cycles(); - out.flush() catch return; - const t3 = cycles(); - - prof_copy_cy = t1 -% t0; - prof_render_cy = t2 -% t1; - prof_flush_cy = t3 -% t2; -} - -/// One cell to the wire, either through vaxis or straight out. -inline fn writeOne(win: vaxis.Window, x: u16, y: u16, cell: *const pardes.Cell, cols: u16) !void { - if (!direct_emit) { - // Changed TO default. `win.clear()` is what used to blank these, and it is not run on an - // incremental frame, so say it explicitly. - if (cell.default) return win.writeCell(x, y, .{ .char = .{ .grapheme = " " }, .style = .{} }); - return win.writeCell(x, y, .{ - .char = .{ .grapheme = cell.grapheme() }, - .style = vaxisStyle(cell.style), - }); - } - - if (emit_row != y or emit_col != x) { - try cup(y, x); - emit_row = y; - emit_col = @intCast(x); - } - - const style: pardes.CellStyle = if (cell.default) .{} else cell.style; - if (!std.meta.eql(emit_style, style)) { - try emitStyle(style); - emit_style = style; - } - - try emitRaw(if (cell.default) " " else cell.grapheme()); - - // Where the terminal's cursor now is. A single printable ASCII byte advanced it exactly one - // column; anything else - a wide glyph, a cluster, the spacer cell pardes writes after a wide - // one - is not worth predicting, so give up and let the next cell emit an absolute CUP. The last - // column is given up on too, because whether the cursor rests on it or has wrapped past it - // depends on the terminal's deferred-wrap behaviour, and the two disagree by a whole row. - if (x + 1 < cols and cell.len == 1 and cell.text[0] >= 0x20 and cell.text[0] < 0x7f) { - emit_col += 1; - } else emit_col = -1; -} - -/// A style as an absolute SGR, always opening with a reset. -/// -/// Absolute rather than a delta from whatever is currently on, and that is what keeps it short -/// enough to be worth having: no per-attribute off-codes, no state to keep beyond the last style -/// emitted, and a frame that gets cut off cannot leave a later cell wearing an earlier one's colour. -/// It costs a few bytes on a style change, against the ~9 of CUP a changed cell is paying anyway. -fn emitStyle(s: pardes.CellStyle) !void { - try emitRaw("\x1b[0"); - if (s.bold) try emitRaw(";1"); - if (s.dim) try emitRaw(";2"); - if (s.italic) try emitRaw(";3"); - if (s.blink) try emitRaw(";5"); - if (s.reverse) try emitRaw(";7"); - if (s.invisible) try emitRaw(";8"); - if (s.strikethrough) try emitRaw(";9"); - try emitRaw(switch (s.ul) { - .off => "", - .single => ";4", - .double => ";4:2", - .curly => ";4:3", - .dotted => ";4:4", - .dashed => ";4:5", - }); - try emitColor(s.fg, 30); - try emitColor(s.bg, 40); - try emitRaw("m"); -} - -/// `base` is 30 for a foreground and 40 for a background, which is the only thing separating the two -/// in every form SGR has for a colour: 30-37 against 40-47, 90-97 against 100-107, 38 against 48. -fn emitColor(c: pardes.Color, comptime base: u16) !void { - var b: [20]u8 = undefined; - var i: usize = 0; - switch (c) { - // Already said by the reset this SGR opens with. - .default => return, - .index => |n| { - b[i] = ';'; - i += 1; - if (n < 8) { - i += dec(b[i..], base + n); - } else if (n < 16) { - i += dec(b[i..], base + 60 + (n - 8)); - } else { - i += dec(b[i..], base + 8); - i += lit(b[i..], ";5;"); - i += dec(b[i..], n); - } - }, - .rgb => |v| { - b[i] = ';'; - i += 1; - i += dec(b[i..], base + 8); - i += lit(b[i..], ";2;"); - for (v, 0..) |component, k| { - if (k != 0) { - b[i] = ';'; - i += 1; - } - i += dec(b[i..], component); - } - }, - } - try emitRaw(b[0..i]); -} - -/// Absolute cursor positioning, hand-rolled rather than through `out.print`. -/// -/// Not for elegance: this is the single most frequent sequence the emitter produces, at least one per -/// changed run, and `std.fmt` brings a whole format-string interpreter to write at most two digits. -/// The grid is bounded by `max_cols` x `max_rows`, so nothing here can exceed three. -fn cup(row: u16, col: u16) !void { - var b: [12]u8 = undefined; - var i: usize = lit(&b, "\x1b["); - i += dec(b[i..], row + 1); - b[i] = ';'; - i += 1; - i += dec(b[i..], col + 1); - b[i] = 'H'; - i += 1; - try emitRaw(b[0..i]); -} - -/// Decimal, least significant digit first into a scratch buffer and then reversed. Five digits is -/// every `u16`, so there is no fallback to `std.fmt` and no value this cannot write. -fn dec(buf: []u8, v: u16) usize { - var digits: [5]u8 = undefined; - var n: usize = 0; - var rest = v; - while (true) { - digits[n] = '0' + @as(u8, @intCast(rest % 10)); - n += 1; - rest /= 10; - if (rest == 0) break; - } - for (0..n) |k| buf[k] = digits[n - 1 - k]; - return n; -} - -inline fn lit(buf: []u8, comptime s: []const u8) usize { - @memcpy(buf[0..s.len], s); - return s.len; -} - -/// Every direct-emit byte goes through here, because the count is what the padding below needs. -inline fn emitRaw(bytes: []const u8) !void { - emit_bytes += bytes.len; - try out.writeAll(bytes); -} - -/// A/B switch for the emitter above, on the same terms as `shadow_grid`: false routes every cell back -/// through vaxis, which is the reference. vaxis's own diff measured 631 us of a 4.37 ms keystroke and -/// all of it was redundant - `present` has already worked out which cells moved, so vaxis was being -/// told the answer and then computing it again from scratch. -const direct_emit = true; - -/// THE FRAME HAS A MINIMUM SIZE, and it is the USB bridge's, not the terminal's. -/// -/// The board is wired to the host through a CH340, and 32 is not a guess: it is `wMaxPacketSize` of -/// endpoint 0x82, the bulk IN, as the device itself reports it - a full-speed 0x0020. The bridge -/// forwards a packet when the packet is FULL, so a frame shorter than that sits there until an -/// internal timer gives up on more, which is worth about a millisecond - a quarter of the budget. -/// -/// Measured, at the same board cost and with the screen byte-identical: a 21-byte frame round-trips -/// in 4817 us and the same frame padded to 49 bytes in 3814 us. MORE BYTES, ARRIVING SOONER. It also -/// explains why routing through vaxis looked competitive - its frames are 81 bytes, so they fill a -/// packet by accident and never wait. -/// -/// So pad to the packet boundary. The filler is repeated absolute cursor positioning: idempotent, -/// already the sequence the emitter ends on, and it cannot alter a cell. This is the same bargain as -/// an Ethernet runt frame - the medium has a minimum and the sender pays it - and it is a real -/// trade, not free: the wasted bytes are wire time that delays a LATER frame, so it is only worth it -/// while the frame is small, which is exactly when it applies. -const emit_min_frame: usize = 32; - -/// Bytes emitted this frame, for `emit_min_frame`. -var emit_bytes: usize = 0; - -/// What the terminal is currently wearing and where its cursor is, so that a run of changed cells in -/// one row costs one CUP and one SGR rather than one of each per cell. `emit_col` is signed because -/// -1 means "no longer known" - see `writeOne`. -var emit_style: pardes.CellStyle = .{}; -var emit_row: u16 = 0; -var emit_col: i32 = -1; - -/// A/B switch, kept because this optimisation is exactly the kind that can be right about latency -/// and wrong about the screen. With it false, `present` behaves as it did before the shadow grid - -/// clear and write every cell - which is the reference any measurement of it should be compared -/// against, and the way to tell a rendering bug from a rendering difference. -const shadow_grid = true; - -/// The previous Surface, cell for cell, sized for the largest grid this board can drive. In `.bss` -/// rather than on the heap: see `present`. `prev_cols`/`prev_rows` being zero on the first frame is -/// what makes that frame a full one. -var prev_cells: [@as(usize, max_cols) * @as(usize, max_rows)]pardes.Cell = if (shadow_grid) @splat(.{}) else undefined; -var prev_cols: u16 = 0; -var prev_rows: u16 = 0; - -/// Cell equality for the shadow grid, as bytes. -/// -/// `Cell.visuallyEqual` is the semantically exact answer and it is too slow to ask 480 times a -/// frame: `std.meta.eql` on a `CellStyle` recurses through a colour union and eight booleans, and -/// the walk measured 1.45 ms - about 270 cycles per comparison of a ~28-byte struct. -/// -/// Byte equality IMPLIES visual equality, so this can never claim two different cells are the same. -/// It can miss an equality - scratch bytes past `len`, or padding - and the only cost of that is one -/// redundant `writeCell` that vaxis then diffs away. Defaults are still handled by meaning rather -/// than by bytes, because an unpainted cell's text and style are whatever the last frame left there. -inline fn sameCell(a: *const pardes.Cell, b: *const pardes.Cell) bool { - if (a.default or b.default) return a.default and b.default; - return sameBytes(std.mem.asBytes(a), std.mem.asBytes(b)); -} - -/// Exact byte equality, a word at a time when both spans are aligned for it. -/// -/// This comparison is the firmware's largest read by a wide margin - two 13 KB streams every frame - -/// and it measured 3.2 cycles per byte, about four times what word-wide loads should need, which is -/// what a byte-at-a-time loop looks like. The answer is identical either way: this is still exact -/// byte equality, so it keeps the property the whole diff rests on, that byte equality implies -/// visual equality. -/// -/// The alignment test is a RUNTIME one because `Cell` has alignment 1 - it is all `u8` fields - so -/// whether a row begins on a word boundary is a property of whoever allocated the Surface and not of -/// the type. A row is 40 cells of 26 bytes, which is divisible by four, so if the base is aligned -/// every row is. When it is not, the byte loop is still here. -inline fn sameBytes(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); -} - -// ------------------------------------------------------------------ where a frame's time goes -// -// A frame has three stages and they want different fixes, so the firmware is given all three rather -// than one total. Measured on the die, a render costs ~11 ms whether or not anything changed, which -// says the cost is the unconditional walk and not the edit - but "the walk" is two walks, the copy -// into vaxis's grid and vaxis's own diff, and only one of them is ours to change. -// -// Two CSR reads per stage. `cycle` is the unprivileged counter, read high-low-high because two -// 32-bit halves can straddle a wrap. -var prof_copy_cy: u64 = 0; -var prof_render_cy: u64 = 0; -var prof_flush_cy: u64 = 0; - -inline fn cycles() u64 { - if (builtin.cpu.arch != .riscv32) return 0; - while (true) { - const hi0 = asm volatile ("csrr %[o], cycleh" - : [o] "=r" (-> u32), - ); - const lo = asm volatile ("csrr %[o], cycle" - : [o] "=r" (-> u32), - ); - const hi1 = asm volatile ("csrr %[o], cycleh" - : [o] "=r" (-> u32), - ); - if (hi0 == hi1) return (@as(u64, hi0) << 32) | lo; - } -} - -/// 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 { - copy.* = prof_copy_cy; - render.* = prof_render_cy; - flush.* = prof_flush_cy; -} - -fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { - return .{ - .fg = vaxisColor(s.fg), - .bg = vaxisColor(s.bg), - .bold = s.bold, - .dim = s.dim, - .italic = s.italic, - .blink = s.blink, - .reverse = s.reverse, - .invisible = s.invisible, - .strikethrough = s.strikethrough, - .ul_style = switch (s.ul) { - .off => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; -} - -fn vaxisColor(c: pardes.Color) vaxis.Color { - return switch (c) { - .default => .default, - .index => |i| .{ .index = i }, - .rgb => |rgb| .{ .rgb = rgb }, - }; -} - -/// Errors cross the ABI as small non-zero integers. `@intFromError` is not stable across builds, so -/// it is not used: the firmware only reports the number, and a stable-looking value that silently -/// changed meaning would be worse than an opaque one. -fn errCode(err: anyerror) u32 { - return switch (err) { - error.OutOfMemory => 1, - error.WriteFailed => 2, - else => 255, - }; -} 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[=]`: 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 ` 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=` 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=\n", + .{n}, + ) catch "pardes: several detached sessions are running; say which with --attach=\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=` 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[=]`: 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 + // ` 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 ` 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 " 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(); + } +} -- cgit v1.3