summaryrefslogtreecommitdiff
path: root/src/detached/wire.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 13:27:46 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch)
tree803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/detached/wire.zig
parentfbc194068687e49a8490c85c9f1257a2f2bb9079 (diff)
downloadpardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz
pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
Diffstat (limited to 'src/detached/wire.zig')
-rw-r--r--src/detached/wire.zig1755
1 files changed, 1755 insertions, 0 deletions
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" } }));
+}