summaryrefslogtreecommitdiff
path: root/src/detached/wire.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/detached/wire.zig')
-rw-r--r--src/detached/wire.zig262
1 files changed, 62 insertions, 200 deletions
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 23a85158..ab19bcfb 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -1,191 +1,28 @@
-//! 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-two methods; this carries FIVE of
-//! them — `push_present` as `frame`, `push_set_clipboard`,
-//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the
-//! seventeen it does not are named here with their reasons. The five are
-//! spelled out because this arithmetic has now gone stale twice in one day,
-//! once when the machine-local eight moved into the daemon and once when
-//! `detach` arrived, and a count nobody can check against a list is a comment
-//! that rots quietly.
-//! * The nine machine-local ones — `push_spawn`, `push_pty_write`,
-//! `push_pty_resize`, `push_pty_signal`, `push_write_file`,
-//! `push_write_dump`, `push_watch_file`, `push_watch_theme`,
-//! `push_dump_themes` — are
-//! performed by the detached core ITSELF, through `host_io.zig`. A unix
-//! socket means it is on the same machine, so there is no question of
-//! whose disk or whose process table is meant, and a pane's shell has to
-//! outlive the frontend that asked for it or a detached session is a
-//! promise it cannot keep. The `ServerTag` doc below carries the whole of
-//! that argument; this line exists so the count at the top of the file
-//! agrees with it.
-//! * `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`.
+//! 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");
-/// 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 version: u16 = 2;
-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,
-};
+pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, 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;
+pub const max_payload: u32 = 16 << 20;
+pub const header_len = 5; // tag:u8, payload length:u32le
-/// 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 run_header = 4 + 2; // start:u32, count:u16
+const run_max = std.math.maxInt(u16);
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. Two 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.
-/// 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.
+// 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);
}
-// ---------------------------------------------------------------------------
-// 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.
-///
-/// EVERY TAG HERE IS SOMETHING A HUMAN DID, and that is the whole set: a
-/// handshake, a goodbye, and what a keyboard, a mouse, a trackpad or a window
-/// manager produces. Six numbers are missing from the input run — 0x13..0x17
-/// and 0x1e — and the gaps are left rather than tidied away, because
-/// renumbering is a change every deployed frontend feels. They were `output`,
-/// `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and `tick`: the
-/// MACHINE-LOCAL host's own reports, which stopped being a frontend's business
-/// when the daemon took the disk and the process table (host_io.zig,
-/// file_watch.zig). No frontend ever produced one — tty.zig's attached loop
-/// swallowed them by name and gui.zig never handed `Input.post` one — and
-/// leaving them DECODABLE was not merely dead weight: server.zig's `apply`
-/// routes any decoded non-resize event straight into `core.update`, so an
-/// attached peer could forge a pane's output, forge an `eof` for a shell that
-/// was still running (and unlike the daemon's own `paneEof` the wire path never
-/// called `closePty`, so the master stayed open and the shell was orphaned for
-/// the life of the session), or replace a pane's text with bytes the next
-/// `Save` would write to disk. client.zig's header says a machine-local effect
-/// cannot return to the wire; deleting these is what makes that true in BOTH
-/// directions instead of only core -> frontend.
+// Stable wire numbers; gaps are retired tags. Unknown tags are rejected by the decoder.
pub const ClientTag = enum(u8) {
hello = 0x01,
bye = 0x02,
@@ -201,23 +38,7 @@ pub const ClientTag = enum(u8) {
pointer_leave = 0x1d,
};
-/// 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.
-///
-/// There are only THREE of those left, and which three is the whole design.
-/// The daemon performs every effect that needs a disk or a process table
-/// itself (see `host_io.zig`): a unix socket means it is on the same machine,
-/// so there is no question of whose disk is meant, and a pane's shell has to
-/// outlive the frontend that asked for it or a detached session is a promise
-/// it cannot keep. What is left on the wire is what a process nobody is
-/// looking at genuinely cannot do — put something on THIS human's clipboard,
-/// read it back, and open a link in front of the person who clicked it.
-///
-/// `detach` is in the SESSION range and not among those three on purpose: it is
-/// not an effect the core wants performed, it is the session telling one
-/// frontend that it is done. `quit` is its sibling — same shape, opposite
-/// meaning about whether anything survives.
+// Session control precedes display-local effects.
pub const ServerTag = enum(u8) {
welcome = 0x01,
refuse = 0x02,
@@ -791,7 +612,11 @@ pub fn encodeFrame(
const start = i;
var run_end = i + 1;
i += 1;
- while (i < cells.len) {
+ // `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;
@@ -840,15 +665,7 @@ fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i
fn clientTag(msg: ClientMsg) ClientTag {
return switch (msg) {
.event => |ev| switch (ev) {
- // SEVEN `Event`s a frontend cannot produce, so no `ClientTag`
- // exists for them and this arm is where the compiler says so.
- // `fs_req` is the acme mount, raised in the same process that
- // answers it. The other six are the machine-local host's own
- // reports — a pty's output and its EOF, a language or pipe worker's
- // answer, a watched file's new bytes, an animation tick — and after
- // the daemon took the disk and the process table every one of them
- // is raised by the process that already holds the core. See
- // `ClientTag` for what putting them back would let a peer forge.
+ // Machine-local reports and 9P requests belong to the session owner.
.output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable,
inline else => |_, t| @field(ClientTag, @tagName(t)),
},
@@ -1382,6 +1199,51 @@ test "detached wire: a full frame carries the grid, a diff carries the change" {
}
}
+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).