//! Detached input and frames use fixed-width little-endian fields and explicit tags. //! Native struct layout never reaches the wire. const std = @import("std"); const pardes = @import("../pardes.zig"); const limits = @import("../memory.zig").limits; /// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value /// an older frontend would reject mid-session. The greeting is where that has /// to be refused, which is what this number is for. /// 8: one layer type: a tag band carries all its rows (not a layer per /// line), and a frame carries the core's placed regions after its layers. pub const version: u16 = 8; pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; pub const max_cols: u16 = 512; pub const max_rows: u16 = 128; pub const max_payload: u32 = 16 << 20; pub const header_len = 5; // tag:u8, payload length:u32le const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1; const run_header = 4 + 2; // start:u32, count:u16 const run_max = std.math.maxInt(u16); const frame_head = 1 + 2 + 2 + 6 + 4 + 4; // Worst case: every cell changed, each in its own run. pub fn frameBound(cols: u16, rows: u16) usize { return header_len + frame_head + @as(usize, cols) * @as(usize, rows) * (run_header + cell_max); } // Stable wire numbers; gaps are retired tags. Unknown tags are rejected by the decoder. pub const ClientTag = enum(u8) { hello = 0x01, bye = 0x02, key = 0x10, mouse = 0x11, resize = 0x12, paste = 0x18, command = 0x19, pdf_scroll = 0x1a, pinch = 0x1b, touch_scroll = 0x1c, pointer_leave = 0x1d, }; // Session control precedes display-local effects. pub const ServerTag = enum(u8) { welcome = 0x01, refuse = 0x02, frame = 0x03, quit = 0x04, /// One frontend is done, and the session is NOT over. The `Detach` word, /// routed back to the frontend whose keystroke ran it (server.zig ORIGIN, /// ELSE PRIMARY, the same rule `read_clipboard` takes and for the same /// reason). Every other frontend, the core and the pane shells are /// untouched, so leaving is success rather than a failure to report. detach = 0x05, set_clipboard = 0x10, read_clipboard = 0x11, open_link = 0x12, }; /// 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, back = 0x08, forward = 0x09, }; 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 }; const FrameHeader = enum(u8) { full = 0x01, diff = 0x02, full_link = 0x03, diff_link = 0x04 }; /// 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, }; /// `Hello.version` alone, read out of the still-undecoded payload. /// /// Separate from `decodeClient` because of the guarantee the fixed offset above /// exists to give: a decoder that validates `cols` and `rows` and then refuses /// trailing bytes can never deliver it. A v2 hello with one more field would be /// closed as a malformed message, and the `Refusal.version` byte a frontend /// needs in order to say something true would never be sent — which is exactly /// the case the field was put at offset zero for. So the version is asked for /// first, on its own, before any of the layout that may have moved. /// /// An error rather than a zero on a payload shorter than two bytes, so a /// truncated hello stays diagnosable too instead of reading as version 0. pub fn helloVersion(payload: []const u8) Error!u16 { var r: Reader = .init(payload); return r.getU16(); } 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 { layers: []const u8 = &.{}, pointer_shape: pardes.Surface.PointerShape = .arrow, kind: FrameKind, cols: u16, rows: u16, cursor: ?Cursor, nruns: u32, runs: []const u8, pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region), chrome: *pardes.Chrome) !void { regions.clearRetainingCapacity(); chrome.* = .{}; for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; } for (tags) |*layer| { gpa.free(layer.cells); layer.* = .{}; } errdefer { for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; } for (tags) |*layer| { gpa.free(layer.cells); layer.* = .{}; } regions.clearRetainingCapacity(); chrome.* = .{}; } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); const count = try r.getU16(); if (count > layers.len) return error.Overlong; for (layers[0..count]) |*layer| { layer.kind = .body; layer.id = try r.getU16(); layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); layer.rows = try r.getU16(); layer.context_rows = try r.getU16(); if (layer.id >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or layer.rows == 0 or layer.rows > 2 * max_rows or layer.context_rows > layer.rows or layer.context_rows > limits.wrap_rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or layer.viewport.y > f.rows or layer.viewport.h > f.rows - layer.viewport.y) return error.BadValue; for (0..layer.context_rows) |row| { if (try r.getBool()) layer.context_separators.set(row); } const cursor = try getCursor(&r, layer.cols, layer.rows); layer.cursor = if (cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null; layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows); for (layer.cells) |*cell| cell.* = try decodeCell(&r); } const tag_count = try r.getU16(); if (tag_count > tags.len) return error.Overlong; for (tags[0..tag_count]) |*layer| { layer.kind = try r.getTag(pardes.TagKind); layer.id = try r.getU16(); layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); layer.rows = try r.getU16(); for (&layer.bg) |*channel| channel.* = try r.getByte(); if (layer.cols == 0 or layer.viewport.w == 0 or layer.rows == 0 or layer.rows > pardes.MAX_TAG_ROWS or layer.viewport.h != layer.rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or layer.viewport.y >= f.rows or layer.viewport.h > f.rows - layer.viewport.y or (switch (layer.kind) { .workspace => layer.id != 0, .column => layer.id >= pardes.MAX_COLS, .pane, .notice => layer.id >= pardes.MAX_PANES, .body => true, })) return error.BadValue; const cursor = try getCursor(&r, layer.cols, layer.rows); layer.cursor = if (cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null; layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows); for (layer.cells) |*cell| cell.* = try decodeCell(&r); } const region_count = try r.getU16(); if (region_count > pardes.MAX_REGIONS) return error.Overlong; try regions.ensureTotalCapacity(gpa, region_count); for (0..region_count) |_| { var region: pardes.Region = .{ .kind = try r.getTag(pardes.Region.Kind), .rect = undefined }; region.owner = try r.getByte(); region.serial = try r.getU32(); region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; region.active = try r.getBool(); region.thumb_y = try r.getU16(); region.thumb_h = try r.getU16(); region.notice = try r.getByte(); region.clip = try r.getU16(); if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; regions.appendAssumeCapacity(region); } inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { const rgb = &@field(chrome, field.name); if (field.type == ?[3]u8) { if (try r.getBool()) { rgb.* = .{ 0, 0, 0 }; for (&rgb.*.?) |*channel| channel.* = try r.getByte(); } } else for (rgb) |*channel| channel.* = try r.getByte(); } try r.end(); } /// 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, /// One frontend is done, and the session is NOT over — see `ServerTag`. detach, set_clipboard: []const u8, read_clipboard, open_link: []const u8, }; // --------------------------------------------------------------------------- // 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 the machine-local reports. 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" (surface.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 { return encodeFramePointer(out, cols, rows, cursor, .arrow, cells, prev); } pub fn encodeFramePointer( out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}, .{}); } pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { var n: usize = 6; for (layers) |layer| if (layer.rows != 0) { n += 26 + layer.context_rows + layer.cells.len * cell_max; }; for (tags) |layer| if (layer.rows != 0) { n += 28 + layer.cells.len * cell_max; }; return n + regions.len * region_len + chrome_len; } const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; /// The palette: each colour three bytes, a theme's optional ones a flag /// before them. const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4; pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) 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)); const header: FrameHeader = if (pointer_shape == .link) (if (full) .full_link else .diff_link) else (if (full) .full else .diff); try w.putByte(@intFromEnum(header)); try w.putU16(cols); try w.putU16(rows); try putCursor(&w, cursor, cols, rows); const layers_len_at = w.n; try w.putU32(0); const layers_start = w.n; if (layers.len != 0 or tags.len != 0 or regions.len != 0) { var count: u16 = 0; for (layers) |layer| if (layer.rows != 0) { count += 1; }; try w.putU16(count); for (layers) |layer| { if (layer.rows == 0) continue; if (layer.context_rows > limits.wrap_rows or layer.context_rows > layer.rows) return error.BadValue; try w.putU16(layer.id); try w.putU32(layer.serial); try w.putU16(layer.viewport.x); try w.putU16(layer.viewport.y); try w.putU16(layer.viewport.w); try w.putU16(layer.viewport.h); try w.putU16(layer.cols); try w.putU16(layer.rows); try w.putU16(layer.context_rows); for (0..layer.context_rows) |row| try w.putBool(layer.context_separators.isSet(row)); try putCursor(&w, if (layer.cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null, layer.cols, layer.rows); for (layer.cells) |*cell| try putCell(&w, cell); } var tag_count: u16 = 0; for (tags) |layer| if (layer.rows != 0) { tag_count += 1; }; try w.putU16(tag_count); for (tags) |layer| { if (layer.rows == 0) continue; if (layer.cells.len != @as(usize, layer.cols) * layer.rows) return error.BadValue; try w.putByte(@intFromEnum(layer.kind)); try w.putU16(layer.id); try w.putU32(layer.serial); try w.putU16(layer.viewport.x); try w.putU16(layer.viewport.y); try w.putU16(layer.viewport.w); try w.putU16(layer.viewport.h); try w.putU16(layer.cols); try w.putU16(layer.rows); for (layer.bg) |channel| try w.putByte(channel); try putCursor(&w, if (layer.cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null, layer.cols, layer.rows); for (layer.cells) |*cell| try putCell(&w, cell); } try w.putU16(@intCast(regions.len)); for (regions) |region| { try w.putByte(@intFromEnum(region.kind)); try w.putByte(region.owner); try w.putU32(region.serial); try w.putU16(region.rect.x); try w.putU16(region.rect.y); try w.putU16(region.rect.w); try w.putU16(region.rect.h); try w.putBool(region.active); try w.putU16(region.thumb_y); try w.putU16(region.thumb_h); try w.putByte(region.notice); try w.putU16(region.clip); } inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { const rgb = @field(chrome, field.name); if (field.type == ?[3]u8) { try w.putBool(rgb != null); if (rgb) |value| for (value) |channel| try w.putByte(channel); } else for (rgb) |channel| try w.putByte(channel); } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); 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; // `i - start` and not `run_end - start`, because the gap lookahead // below moves `i` ahead of `run_end` by up to `run_header - 1` before // the next iteration adopts it: bounding the cursor bounds the run, // and bounding the run afterwards would not. while (i < cells.len and i - start < run_max) { 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) { // Machine-local reports and 9P requests belong to the session owner. .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => 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"); fn putMetrics(w: *Writer, metrics: ?pardes.RowMetrics) Error!void { try w.putBool(metrics != null); if (metrics) |m| { try w.putU16(m.body_w); try w.putU16(m.body_h); try w.putU16(m.tagline_w); try w.putU16(m.tagline_h); } } fn getMetrics(r: *Reader) Error!?pardes.RowMetrics { if (!try r.getBool()) return null; const m: pardes.RowMetrics = .{ .body_w = try r.getU16(), .body_h = try r.getU16(), .tagline_w = try r.getU16(), .tagline_h = try r.getU16() }; if (m.body_w == 0 or m.body_h == 0 or m.tagline_w == 0 or m.tagline_h == 0) return error.BadValue; return m; } fn putBodyHit(w: *Writer, hit: ?pardes.Mouse.BodyHit) Error!void { try w.putBool(hit != null); if (hit) |h| { try w.putU32(h.serial); try w.putU16(h.row); try w.putU16(h.col); try w.putF32(h.pixel_x); try w.putF32(h.pixel_y); try putMetrics(w, h.metrics); } } fn getBodyHit(r: *Reader) Error!?pardes.Mouse.BodyHit { if (!try r.getBool()) return null; const hit: pardes.Mouse.BodyHit = .{ .serial = try r.getU32(), .row = try r.getU16(), .col = try r.getU16(), .pixel_x = try r.getF32(), .pixel_y = try r.getF32(), .metrics = (try getMetrics(r)) orelse return error.BadValue }; if (!std.math.isFinite(hit.pixel_x) or !std.math.isFinite(hit.pixel_y)) return error.BadValue; return hit; } fn putTagHit(w: *Writer, hit: ?pardes.TagHit) Error!void { try w.putBool(hit != null); if (hit) |h| { try w.putByte(@intFromEnum(h.kind)); try w.putU16(h.id); try w.putU32(h.serial); try w.putU16(h.col); try w.putF32(h.pixel_x); try w.putF32(h.pixel_y); try putMetrics(w, h.metrics); } } fn getTagHit(r: *Reader) Error!?pardes.TagHit { if (!try r.getBool()) return null; const hit: pardes.TagHit = .{ .kind = try r.getTag(pardes.TagKind), .id = try r.getU16(), .serial = try r.getU32(), .col = try r.getU16(), .pixel_x = try r.getF32(), .pixel_y = try r.getF32(), .metrics = (try getMetrics(r)) orelse return error.BadValue, }; if (!std.math.isFinite(hit.pixel_x) or !std.math.isFinite(hit.pixel_y)) return error.BadValue; return hit; } 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); try putBodyHit(&w, m.body_hit); try putTagHit(&w, m.tag_hit); }, .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); try putMetrics(&w, rs.row_metrics); }, .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 => {}, // See `clientTag`: no tag, so nothing to encode. .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, }, } try finishMessage(&w, at); return w.written(); } pub fn decodeClient(tag: u8, payload: []const u8) 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(), .body_hit = try getBodyHit(&r), .tag_hit = try getTagHit(&r), } } }, .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 }; ev.resize.row_metrics = try getMetrics(&r); break :blk .{ .event = ev }; }, .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 }, }; 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, .detach => {}, .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 + 64; /// 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, .detach, .read_clipboard => 0, // A frame is bounded by its grid, not by this: see `frameBound`. .frame => |f| frameBound(f.cols, f.rows), .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, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0, .key => |k| k.text.len, .paste => |b| b.len, .command => |line| line.len, // See `clientTag`: not on this wire in this direction. .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => 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 header = try r.getTag(FrameHeader); const kind: FrameKind = switch (header) { .full, .full_link => .full, .diff, .diff_link => .diff, }; const cols = try r.getCols(); const rows = try r.getRows(); const cursor = try getCursor(&r, cols, rows); const layers = try r.getSlice32(); 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 = .{ .layers = layers, .kind = kind, .pointer_shape = if (header == .full_link or header == .diff_link) .link else .arrow, .cols = cols, .rows = rows, .cursor = cursor, .nruns = nruns, .runs = runs, } }; }, .quit => .quit, .detach => .detach, .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) !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); } 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; // 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, 0x1d), @intFromEnum(ClientTag.pointer_leave)); { const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }); 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)); { const key: pardes.Key = .{ .cp = pardes.Key.page_down, .text = "ü", .ctrl = true, .alt = false, .shift = true }; const got = (try roundClient(&buf, .{ .event = .{ .key = key } })).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 } })).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 } } })).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); } } try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } })).event.paste); try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } })).event.command); { const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } })).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 } })).event.pinch); try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } })).event.touch_scroll); try testing.expectEqual( std.meta.Tag(pardes.Event).pointer_leave, (try roundClient(&buf, .{ .event = .pointer_leave })).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)); try testing.expectEqual(ServerMsg.detach, try roundServer(&buf, .detach)); 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); } test "detached wire: only the display's own effects are on the wire" { // Two guards, because the mistake has two directions. The exhaustive // switch fails to COMPILE if a machine-local effect is added back, which // is the direction that matters: it would hand a frontend work that must // outlive it. The count fails if one of the three is dropped, which would // silently leave a clipboard or a link unanswered on every frontend. try testing.expectEqual(@as(usize, 8), std.enums.values(ServerTag).len); for (std.enums.values(ServerTag)) |t| switch (t) { .welcome, .refuse, .frame, .quit, .detach, .set_clipboard, .read_clipboard, .open_link => {}, }; } test "detached wire: a session is never told to do a frontend's remembering" { // The mirror of the test above, and of client.zig's "a frontend is never // asked to fork, write, or watch". That one pins what may reach a FRONTEND; // this one pins what may reach the SESSION, and until the six tags below it // names were deleted the protocol was asymmetric: server -> client carried // only what a display can do, while client -> server still carried the // machine-local host's own reports, which server.zig's `apply` hands // straight to `core.update`. // // Adding a name here is meant to be an ARGUMENT, not a formality, and the // bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a // window resized, a paste, a command line executed, a pointer leaving the // window — plus the two session words that say who is speaking. A pty's // output, a worker's answer, a watched file's new bytes and an animation // tick all fail that bar the same way: nobody did them, a machine reported // them, and the machine that reports them is the one already holding the // core. const allowed = [_][]const u8{ // Who this connection is, and that it is finished. "hello", "bye", // ...and everything a person can do to a window. "key", "mouse", "resize", "paste", "command", "pdf_scroll", "pinch", "touch_scroll", "pointer_leave", }; // One: the tag set is exactly that, named rather than counted, so // re-adding `output` fails with the name in the failure. inline for (std.enums.values(ClientTag)) |t| { for (allowed) |ok| { if (std.mem.eql(u8, @tagName(t), ok)) break; } else { std.debug.print("ClientTag.{s} is not something a human did\n", .{@tagName(t)}); return error.MachineLocalReportOnTheWire; } } try testing.expectEqual(allowed.len, std.enums.values(ClientTag).len); // Two: and no tag BYTE outside them decodes either — the check above is // about this build's enum, this one is about the bytes on the socket. The // six deleted numbers (0x13..0x17, 0x1e) are in the 250 that must answer // `BadTag`, so a frontend built before this change cannot forge a pane's // output into a session built after it. var accepted: usize = 0; for (0..256) |i| { const tag: u8 = @intCast(i); // Empty payloads on purpose: what is asked is whether the TAG is known. // A known one fails later (`Truncated`) or succeeds, never with // `BadTag`. if (decodeClient(tag, &.{})) |_| accepted += 1 else |err| switch (err) { error.BadTag => continue, else => accepted += 1, } const named = for (allowed) |ok| { const want = std.meta.stringToEnum(ClientTag, ok).?; if (@intFromEnum(want) == tag) break true; } else false; if (!named) { std.debug.print("tag 0x{x:0>2} is decodable by a session and is not something a human did\n", .{tag}); return error.MachineLocalReportOnTheWire; } } try testing.expectEqual(allowed.len, accepted); } /// 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 largest grid is one cell past a run, and survives it" { // `max_cols * max_rows` is 65536 and a run's `count` is a u16, so THE ONE // grid this protocol calls legal at both bounds is the one grid whose full // frame cannot be described by a single run. It reached nobody while a // frontend sent its window raw — a display that big had its hello refused // before any frame was composed — and it is the ordinary case now that a // frontend clamps to exactly this. In a safe build the `@intCast` panicked // and took the session down; in a fast one it wrote a zero-length run the // frontend answered with `BadValue`. const cols = max_cols; const rows = max_rows; 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); // Every cell painted, which is what `render` produces and therefore what a // full frame always is: one run, if a run could be that long. for (cells) |*c| c.* = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; @memset(mirror, .{}); const bytes = try encodeFrame(buf, cols, rows, null, cells, &.{}); const f = (try framed(bytes)).?; const msg = (try decodeServer(f.tag, f.payload)).frame; try testing.expectEqual(FrameKind.full, msg.kind); // Split, and split as late as it can be: 65535 cells then 1. try testing.expectEqual(@as(u32, 2), msg.nruns); try msg.apply(mirror); try expectGridEqual(cells, mirror); // ...at exactly the two headers the split costs and not one byte more. // `bytes.len <= frameBound(...)` would prove nothing here — the buffer IS // `frameBound` bytes, so an over-run is `NoSpace` above, not a false // assertion here — and what wants pinning is that the second run is the // whole of the cost. try testing.expectEqual( @as(usize, header_len + frame_head + 2 * run_header + n * 8), bytes.len, ); } 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, 6302), 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, 60), 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; // 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])); } // 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.pointer_leave), "x")); try testing.expectError(error.Trailing, decodeServer(@intFromEnum(ServerTag.quit), "x")); } test "detached wire: an unknown tag is refused, never guessed" { // 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, "")); 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), &.{ 0xff, 0x00, 0, 0, 0, 0, 0 }, )); } 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 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)); // ...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)); } 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(0); // no body layers 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: link pointer survives full and unchanged grid frames" { const cells = [_]pardes.Cell{.{}}; var buffer: [256]u8 = undefined; for ([_]pardes.Surface.PointerShape{ .link, .arrow, .link }) |shape| { for ([_]bool{ true, false }) |full| { const bytes = try encodeFramePointer(&buffer, 1, 1, null, shape, &cells, if (full) &.{} else &cells); const packet = (try framed(bytes)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; try std.testing.expectEqual(shape, frame.pointer_shape); try std.testing.expectEqual(if (full) FrameKind.full else FrameKind.diff, frame.kind); if (!full) try std.testing.expectEqual(@as(u32, 0), frame.nruns); } } } 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); // no body layers 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" { // 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 }, )); // 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 }, )); // A pane the core cannot index. `pdf_scroll` reads its pane byte before the // f32 that follows, so a one-byte payload reaches the check this is about // rather than tripping `Truncated` first. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.pdf_scroll), &.{pardes.MAX_PANES}, )); // 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 }, )); { 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)); } // 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)); std.mem.writeInt(u32, &pinch, @bitCast(std.math.inf(f32)), .little); try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch)); } // 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" } })); } test "detached wire carries compact body rows and exact pointer metrics" { const gpa = std.testing.allocator; var cells: [8]pardes.Cell = @splat(.{}); var logical: [12]pardes.Cell = @splat(.{}); logical[10] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; var layer: pardes.Layer = .{ .id = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; layer.context_separators.set(0); var tag_cells: [3]pardes.Cell = @splat(.{}); tag_cells[2] = logical[10]; const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } }; const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); var tags: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}); defer for (&tags) |*item| gpa.free(item.cells); defer for (&decoded) |*item| gpa.free(item.cells); try frame.apply(&cells); var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; defer regions.deinit(gpa); var chrome: pardes.Chrome = .{}; try frame.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqualDeep(placed[0..], regions.items); try std.testing.expectEqualDeep(palette, chrome); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); try std.testing.expect(decoded[0].hasContextBorderAfter(0)); try std.testing.expect(decoded[0].hasContextBorderAfter(1)); try std.testing.expectEqualStrings("z", decoded[0].cells[10].grapheme()); try std.testing.expectEqual(@as(u16, 5), decoded[0].cursor.?.y); try std.testing.expectEqual(tag.kind, tags[0].kind); try std.testing.expectEqual(tag.id, tags[0].id); try std.testing.expectEqual(tag.bg, tags[0].bg); try std.testing.expectEqual(tag.cols, tags[0].cols); try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.x); try std.testing.expectEqualStrings("z", tags[0].cells[2].grapheme()); const pointer = layer.bodyHit(4, 27, 8, 16, 6, 8).?; const tag_pointer = tag.tagHit(11, 8, 8, 16, 4, 8).?; const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer, .tag_hit = tag_pointer } } }; var mouse_bytes: [256]u8 = undefined; const mouse_encoded = try encodeClient(&mouse_bytes, message); const mouse_packet = (try framed(mouse_encoded)).?; const mouse = (try decodeClient(mouse_packet.tag, mouse_packet.payload)).event.mouse; try std.testing.expectEqualDeep(pointer, mouse.body_hit.?); try std.testing.expectEqualDeep(tag_pointer, mouse.tag_hit.?); try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions, &chrome)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); try std.testing.expectEqual(@as(usize, 0), item.context_separators.count()); } for (tags) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); } try std.testing.expectEqual(@as(usize, 0), regions.items.len); const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; try cleared.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); }