summaryrefslogtreecommitdiff
path: root/src/detached/client.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 13:27:46 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch)
tree803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/detached/client.zig
parentfbc194068687e49a8490c85c9f1257a2f2bb9079 (diff)
downloadpardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz
pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
Diffstat (limited to 'src/detached/client.zig')
-rw-r--r--src/detached/client.zig866
1 files changed, 866 insertions, 0 deletions
diff --git a/src/detached/client.zig b/src/detached/client.zig
new file mode 100644
index 00000000..8d118782
--- /dev/null
+++ b/src/detached/client.zig
@@ -0,0 +1,866 @@
+//! THE FRONTEND SIDE of a detached session: a socket, a grid, and no core.
+//!
+//! THIS SIDE DOES NOT OWN A `Pardes`. That is the one thing to be clear about,
+//! because the shape invites the mistake: there is no `update`, no `postEvent`
+//! and no `Host` in this file. The core lives in the detached process
+//! (server.zig), which is also where every `Host.VTable` call originates. A
+//! frontend's whole job is two sentences long — send the input it collects, draw
+//! the frames it is sent — and this module is exactly that and nothing more.
+//!
+//! `Client` is NOT a renderer either. It owns `grid` and `cursor`: the cells the
+//! session is showing, kept current by applying frames as they arrive. Whoever
+//! owns a terminal (or a window, or an ESP32-P4 panel) reads those and paints.
+//! The split is deliberate — pardes already has terminal frontends, and a second
+//! one living in here would be a second convention for a job that has one.
+//!
+//! THE ATTACH IS NOT A BLOCKING HANDSHAKE. `open` connects and writes the
+//! `hello`; the `welcome` (or the `refuse`) arrives through the ordinary
+//! `wait`/`next` loop like everything else. A frontend therefore has one loop
+//! and one place it sleeps, instead of a startup path that can hang for two
+//! seconds before its terminal is even in raw mode. `attached()` says whether
+//! the session has greeted us; `refusal` says why it did not.
+//!
+//! WHAT ARRIVES, and what a frontend is expected to do with it. `next` hands
+//! back one decoded `wire.ServerMsg` at a time:
+//! * `welcome` and `frame` have already been applied to `slot`/`cols`/`rows`/
+//! `grid`/`cursor` by the time you see them. They are returned so a frontend
+//! knows the screen moved.
+//! * `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`,
+//! `watch_file`, `watch_theme`, `dump_themes` are the session asking this
+//! frontend for the real host work it has and a daemon does not: fork a
+//! shell on a real tty, put bytes on a real disk, watch a path. A frontend
+//! with none of that ignores them, exactly as a null vtable method does —
+//! and only ONE attached frontend is ever asked (server.zig's routing).
+//! * `set_clipboard`, `open_link` and `read_clipboard` are the desktop. The
+//! answer to `read_clipboard` is not a reply message: it is an ordinary
+//! `Event.paste` sent back through `send`, which is the same asynchronous
+//! shape `pull_read_clipboard` already has in-process.
+//! * `refuse` is followed by the session closing the connection, and `quit`
+//! means the session itself has ended.
+//!
+//! GEOMETRY. `cols`/`rows` are the SESSION's grid, which with several frontends
+//! attached is the smallest common one and can be smaller than this frontend's
+//! window. Tell the session about the window with `resize`; do not assume the
+//! next frame will agree with it.
+//!
+//! BORROWED BYTES. Every slice in a returned `ServerMsg` points into this
+//! client's receive buffer and is valid until the next call to `next` or
+//! `wait`. A frontend that needs a path or a payload for longer copies it — the
+//! same rule the core's own `Event.output` bytes have.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("../pardes.zig");
+const server = @import("server.zig");
+const wire = @import("wire.zig");
+
+const read_chunk = 16 * 1024;
+
+pub const Error = error{
+ /// No `$XDG_RUNTIME_DIR` and no `$HOME`, or a name that is not one path
+ /// component — there is no socket path to try.
+ NoSessionPath,
+ /// Nothing is listening there: the name is wrong, or that session ended.
+ /// Its socket file, if it is still lying about, is unlinked by the sweep the
+ /// next detached session runs.
+ NoSession,
+ /// The socket is there and this frontend will not talk to it: the directory
+ /// or the socket is not a private one of ours (server.zig `vetted`). A
+ /// planted socket at a derivable path collects every keystroke typed into
+ /// the frontend that trusts it, so this is refused rather than reported as
+ /// "no session" — the two need different answers from a human.
+ NotPrivate,
+ /// The session hung up, or the connection failed under us. Every read and
+ /// write path funnels here: a frontend's answer to all of them is the same
+ /// (report and exit), so distinguishing them would be a distinction nobody
+ /// acts on.
+ Closed,
+ /// The session said something before it said `welcome`.
+ Ungreeted,
+};
+
+pub const Client = struct {
+ gpa: std.mem.Allocator,
+ fd: c_int = -1,
+ /// Which client slot the session gave this connection. Diagnostics only,
+ /// and it exists so both sides print the same number.
+ slot: u8 = 0,
+ /// Set by a `refuse`, and the reason a frontend prints before exiting.
+ refusal: ?wire.Refusal = null,
+ /// The SESSION's grid, not this frontend's window. Zero until the welcome.
+ cols: u16 = 0,
+ rows: u16 = 0,
+ /// `cols * rows` cells: what the session is showing right now.
+ grid: std.ArrayListUnmanaged(pardes.Cell) = .empty,
+ cursor: ?wire.Cursor = null,
+ in: std.ArrayListUnmanaged(u8) = .empty,
+ out: std.ArrayListUnmanaged(u8) = .empty,
+ /// Bytes of `in` belonging to the message `next` returned last. Compacted at
+ /// the top of the next call, which is exactly what makes that message's
+ /// borrowed slices valid until then and no longer.
+ held: usize = 0,
+
+ /// Connect to the session called `name` and say hello, telling it this
+ /// frontend's window. Does not wait: the greeting arrives through `next`.
+ pub fn open(gpa: std.mem.Allocator, name: []const u8, cols: u16, rows: u16) (Error || wire.Error)!Client {
+ var path_buf: [server.path_max]u8 = undefined;
+ const path = server.sessionPath(&path_buf, name) orelse return error.NoSessionPath;
+ // Both ends vet, and this is this end's half: the session refuses a
+ // directory or a socket anyone else can reach before it binds, and until
+ // this a frontend connected to whatever it found at the path it derived.
+ // See server.zig `vetted` for what is asked and why it is asked there.
+ if (!server.vetted(path)) return error.NotPrivate;
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return error.NoSession;
+ // CLOEXEC before anything can fork, and a frontend DOES fork: the pane
+ // shells it is asked to spawn are its own children, and one of them
+ // holding this socket would keep the session believing a frontend is
+ // attached long after this process left.
+ server.setCloexec(fd);
+ if (comptime server.darwin) {
+ // linux says MSG_NOSIGNAL per write and darwin says it once per
+ // socket, which is what `nosignal` being 0 on darwin MEANS — so
+ // this call is the whole of that platform's protection and the
+ // comment on `nosignal` used to say `open` made it without `open`
+ // making it. A session that ends mid-write must not take the
+ // frontend down with SIGPIPE. server.zig `accept` is the mirror.
+ const on: c_int = 1;
+ _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int));
+ }
+ // Still blocking for the connect itself, which on AF_UNIX either lands
+ // in the listener's backlog immediately or is refused; there is no
+ // in-progress state to poll for.
+ if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ _ = libc.close(fd);
+ return error.NoSession;
+ }
+ server.setNonblock(fd);
+ var c: Client = .{ .gpa = gpa, .fd = fd };
+ errdefer c.deinit();
+ try c.send(.{ .hello = .{ .cols = cols, .rows = rows } });
+ return c;
+ }
+
+ /// Has the session greeted us? Until it has, `grid` is empty and nothing
+ /// has been drawn.
+ pub fn attached(c: *const Client) bool {
+ return c.cols != 0;
+ }
+
+ pub fn deinit(c: *Client) void {
+ if (c.fd >= 0) _ = libc.close(c.fd);
+ c.fd = -1;
+ c.grid.deinit(c.gpa);
+ c.in.deinit(c.gpa);
+ c.out.deinit(c.gpa);
+ }
+
+ /// Leave without ending the session. The `bye` is a courtesy — the session
+ /// handles a frontend that simply dies, and proving it does is what that
+ /// test is for — but it turns "the peer vanished" into "the peer left" in
+ /// the session's log, which is worth seven bytes.
+ pub fn detach(c: *Client) void {
+ c.send(.bye) catch {};
+ c.deinit();
+ }
+
+ /// One message on its way to the core. Everything a frontend collects goes
+ /// through here: keys, the mouse, pty output from the shells it forked, a
+ /// paste answering a `read_clipboard`.
+ pub fn send(c: *Client, msg: wire.ClientMsg) (Error || wire.Error)!void {
+ const want = wire.clientBound(msg);
+ c.out.ensureUnusedCapacity(c.gpa, want) catch return error.Closed;
+ const at = c.out.items.len;
+ // Encoded straight into the queue's tail rather than through a scratch
+ // buffer: a paste is four megabytes and copying it twice is two copies.
+ c.out.items.len += want;
+ const bytes = wire.encodeClient(c.out.items[at..], msg) catch |err| {
+ // AND THE QUEUE GOES BACK. `want` bytes of it are uninitialised
+ // right now, and leaving them there — which is what a bare `try`
+ // did — puts that much stack-shaped garbage on the socket at the
+ // next flush: the session decodes it as a message, refuses it, and
+ // drops a frontend whose only mistake was a message this protocol
+ // cannot carry (an `Event.command` past 64 KiB is `Overlong`, and a
+ // caller that mis-sized the queue is `NoSpace`).
+ c.out.items.len = at;
+ return err;
+ };
+ c.out.items.len = at + bytes.len;
+ return c.flush();
+ }
+
+ /// Tell the session this frontend's window changed. Not a promise about the
+ /// next frame: with other frontends attached the session grid is the
+ /// smallest common one.
+ pub fn resize(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void {
+ return c.send(.{ .event = .{ .resize = .{ .cols = cols, .rows = rows } } });
+ }
+
+ /// Wait up to `timeout_ms` for the session to say something, and push
+ /// whatever we still owe it. Zero blocks. This is the frontend's one
+ /// sleeping place FOR THE SOCKET; the terminal it draws on is polled by
+ /// whoever owns that, which is why this takes a timeout rather than a second
+ /// descriptor.
+ pub fn wait(c: *Client, timeout_ms: u32) (Error || wire.Error)!void {
+ if (c.fd < 0) return error.Closed;
+ try c.flush();
+ var fds: [1]libc.pollfd = .{.{
+ .fd = c.fd,
+ .events = if (c.out.items.len != 0) poll_in | poll_out else poll_in,
+ .revents = 0,
+ }};
+ const timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
+ if (libc.poll(&fds, 1, timeout) <= 0) return; // a timeout, or EINTR
+ if (fds[0].revents & poll_out != 0) try c.flush();
+ // POLLIN wins over POLLHUP: a session that wrote a `quit` and then
+ // closed has bytes worth reading.
+ if (fds[0].revents & poll_in != 0) return c.fill();
+ if (fds[0].revents & (poll_hup | poll_err | poll_nval) != 0) return error.Closed;
+ }
+
+ /// The next complete message, or null when the buffer holds only part of
+ /// one. `welcome` and `frame` have already been applied to this client's
+ /// own state; every slice in the result borrows the receive buffer until the
+ /// next call here or to `wait`.
+ pub fn next(c: *Client) (Error || wire.Error)!?wire.ServerMsg {
+ // Retire the message returned last, now that its borrow window is over.
+ if (c.held != 0) {
+ if (c.held == c.in.items.len) {
+ c.in.clearRetainingCapacity();
+ } else {
+ std.mem.copyForwards(u8, c.in.items, c.in.items[c.held..]);
+ c.in.items.len -= c.held;
+ }
+ c.held = 0;
+ }
+ const found = (try wire.framed(c.in.items)) orelse return null;
+ const msg = try wire.decodeServer(found.tag, found.payload);
+ c.held = found.total;
+ switch (msg) {
+ .welcome => |v| {
+ // Both sides check the version. This side checks it too because
+ // a session speaking something else may not have recognised our
+ // hello as one either, and a frontend must not paint a frame it
+ // decoded by a layout the other end does not use.
+ if (v.version != wire.version) return error.Ungreeted;
+ c.slot = v.slot;
+ try c.reshape(v.cols, v.rows);
+ },
+ .refuse => |why| c.refusal = why,
+ .frame => |f| {
+ // A frame before the greeting would be the session drawing for a
+ // connection it never accepted.
+ if (!c.attached()) return error.Ungreeted;
+ // A geometry change and a late attach are the same case on this
+ // side too: reshape, and require the FULL frame the session
+ // promises for it. Applying a diff to a grid we just cleared
+ // would leave every untouched cell blank.
+ if (f.cols != c.cols or f.rows != c.rows) {
+ if (f.kind != .full) return error.BadValue;
+ try c.reshape(f.cols, f.rows);
+ }
+ try f.apply(c.grid.items);
+ c.cursor = f.cursor;
+ },
+ // The session has ended. Left for the caller to act on, and the
+ // descriptor stays open so `deinit` is the only place that closes.
+ .quit => {},
+ else => {},
+ }
+ return msg;
+ }
+
+ // ---- internals --------------------------------------------------------
+
+ fn reshape(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void {
+ c.grid.resize(c.gpa, @as(usize, cols) * @as(usize, rows)) catch return error.Closed;
+ // Unpainted, which a frontend draws as the terminal's own default cell.
+ // The full frame that follows paints over it.
+ @memset(c.grid.items, .{});
+ c.cols = cols;
+ c.rows = rows;
+ c.cursor = null;
+ }
+
+ /// Take everything the kernel is holding, not one chunk of it. The server
+ /// deliberately reads its clients ONE chunk per round, because it is
+ /// dividing a loop between thirty-two of them; a frontend has exactly one
+ /// peer, and reading one 16 KiB slice per poll would leave it a full frame
+ /// behind on every large one — and the session drops a frontend whose queue
+ /// it cannot drain (server.zig `out_backlog`).
+ fn fill(c: *Client) (Error || wire.Error)!void {
+ var buf: [read_chunk]u8 = undefined;
+ while (true) {
+ const got = libc.read(c.fd, &buf, buf.len);
+ if (got == 0) return error.Closed;
+ if (got < 0) return switch (libc.errno(got)) {
+ .INTR => continue,
+ // Nothing more is ready; what we have is what there was.
+ .AGAIN => {},
+ else => error.Closed,
+ };
+ c.in.appendSlice(c.gpa, buf[0..@intCast(got)]) catch return error.Closed;
+ }
+ }
+
+ fn flush(c: *Client) Error!void {
+ if (c.fd < 0) return error.Closed;
+ var off: usize = 0;
+ while (off < c.out.items.len) {
+ const n = libc.send(c.fd, c.out.items.ptr + off, c.out.items.len - off, nosignal);
+ if (n < 0) switch (libc.errno(n)) {
+ .INTR => continue,
+ // The session is not draining us. The rest waits for POLLOUT;
+ // the queue is bounded in practice because a frontend's output
+ // is keystrokes and pty chunks, never frames.
+ .AGAIN => break,
+ else => return error.Closed,
+ };
+ if (n == 0) break;
+ off += @intCast(n);
+ }
+ if (off == 0) return;
+ if (off == c.out.items.len) return c.out.clearRetainingCapacity();
+ std.mem.copyForwards(u8, c.out.items, c.out.items[off..]);
+ c.out.items.len -= off;
+ }
+};
+
+// The socket primitives are server.zig's, which is the file that owns this
+// transport's conventions and both ends of it — see its `setNonblock`,
+// `nosignal` and `poll_*`. There was a third copy of all of them here.
+const nosignal = server.nosignal;
+const poll_in = server.poll_in;
+const poll_out = server.poll_out;
+const poll_hup = server.poll_hup;
+const poll_err = server.poll_err;
+const poll_nval = server.poll_nval;
+
+// ---------------------------------------------------------------------------
+// tests
+// ---------------------------------------------------------------------------
+//
+// One real core, one real unix socket, real frontends. The harness runs the
+// server's vtable in the order `Pardes.pump` runs it, so what these exercise is
+// the transport as the core actually drives it rather than a mock of it.
+
+const testing = std.testing;
+const host_api = @import("../host.zig");
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+
+/// A session on a socket of its own under `.zig-cache/tmp`, so a test never
+/// collides with a real session in `$XDG_RUNTIME_DIR` and never depends on that
+/// variable being set at all. It is process-wide, so it is saved and restored.
+const Harness = struct {
+ tmp: std.testing.TmpDir,
+ saved: ?[:0]const u8,
+ saved_buf: [4096:0]u8 = undefined,
+ core: *pardes.Pardes,
+ session: server.Session,
+ arena: std.heap.ArenaAllocator,
+ name_buf: [32]u8 = undefined,
+ name: []const u8 = &.{},
+
+ fn init(h: *Harness, cols: u16, rows: u16) !void {
+ h.tmp = std.testing.tmpDir(.{});
+ errdefer h.tmp.cleanup();
+ h.saved = if (libc.getenv("XDG_RUNTIME_DIR")) |v|
+ try std.fmt.bufPrintSentinel(&h.saved_buf, "{s}", .{std.mem.span(v)}, 0)
+ else
+ null;
+ errdefer h.restoreEnv();
+ var dir_buf: [4096:0]u8 = undefined;
+ const dir = try std.fmt.bufPrintSentinel(&dir_buf, ".zig-cache/tmp/{s}", .{h.tmp.sub_path}, 0);
+ // `ensureSocketDir` refuses anything with a bit granted to group or
+ // other, which is the whole point of it; a tmpDir arrives 0755.
+ try testing.expectEqual(@as(c_int, 0), libc.chmod(dir, 0o700));
+ _ = setenv("XDG_RUNTIME_DIR", dir.ptr, 1);
+ h.core = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = cols, .rows = rows });
+ errdefer h.core.deinit();
+ h.arena = .init(testing.allocator);
+ errdefer h.arena.deinit();
+ h.session = .{ .gpa = testing.allocator, .core = h.core, .cols = cols, .rows = rows };
+ h.name = "s";
+ try testing.expect(h.session.listen(h.name));
+ // The pre-loop drain tty.zig has, for its reason: the startup spawns are
+ // already queued and a session must not open its socket with panes that
+ // have not been created.
+ h.core.host = h.session.host();
+ while (h.core.nextEffect()) |e| h.core.perform(e);
+ }
+
+ fn restoreEnv(h: *Harness) void {
+ if (h.saved) |v| {
+ _ = setenv("XDG_RUNTIME_DIR", v.ptr, 1);
+ } else _ = unsetenv("XDG_RUNTIME_DIR");
+ }
+
+ fn deinit(h: *Harness) void {
+ h.session.deinit();
+ h.core.deinit();
+ h.arena.deinit();
+ h.restoreEnv();
+ h.tmp.cleanup();
+ }
+
+ /// `Pardes.pump`, with the one substitution a single-threaded test needs: a
+ /// bounded wait, so a frontend that says nothing cannot hang the suite
+ /// where the real session would sleep until it spoke. The queued-input
+ /// drain `pump` does between the two is absent because this host has no
+ /// queue: it calls `update` directly (borrowed bytes), and `postEvent` is
+ /// for hosts whose worker threads post from off the loop.
+ fn pump(h: *Harness) !void {
+ const host = h.session.host();
+ host.vtable.pull_wait_input.?(host.ctx, 20);
+ while (h.core.nextEffect()) |e| h.core.perform(e);
+ if (h.core.quit) return;
+ _ = h.arena.reset(.retain_capacity);
+ const surface = try h.core.render(h.arena.allocator());
+ host.vtable.push_present.?(host.ctx, surface);
+ }
+
+ /// Pump until this client has the message we are waiting for. Bounded, so a
+ /// broken transport fails a test rather than hanging the suite.
+ fn pumpUntil(h: *Harness, c: *Client, comptime want: std.meta.Tag(wire.ServerMsg)) !wire.ServerMsg {
+ for (0..64) |_| {
+ try h.pump();
+ try c.wait(5);
+ while (try c.next()) |msg| if (std.meta.activeTag(msg) == want) return msg;
+ }
+ return error.NeverArrived;
+ }
+
+ /// Pump until this client is sent a frame that CHANGES something. The first
+ /// frame after an input is not always the one carrying it — an effect the
+ /// input queued (a `Look` on a directory emits a spawn) lands a frame
+ /// later, and the frame in between legitimately says nothing.
+ fn pumpUntilChange(h: *Harness, c: *Client) !wire.Frame {
+ for (0..64) |_| {
+ const msg = try h.pumpUntil(c, .frame);
+ if (msg.frame.nruns > 0) return msg.frame;
+ }
+ return error.NothingChanged;
+ }
+
+ /// Pump until this client's grid is this shape, draining everything that
+ /// arrives. A test waits for the STATE rather than for the n-th message
+ /// because a resize is announced when the session settles it, which may be
+ /// one empty frame after the pump that caused it.
+ fn pumpUntilGrid(h: *Harness, c: *Client, cols: u16, rows: u16) !void {
+ for (0..64) |_| {
+ try h.pump();
+ try c.wait(5);
+ while (try c.next()) |_| {}
+ if (c.cols == cols and c.rows == rows) return;
+ }
+ return error.NeverResized;
+ }
+
+ /// Pump until two frontends are showing the same screen, draining both on
+ /// every pass. One pump sends every attached frontend a frame, so a test
+ /// that drains only one of them is comparing two different instants — and
+ /// what a shared session promises is that they CONVERGE, which is what this
+ /// waits for.
+ fn pumpUntilSameScreen(h: *Harness, a: *Client, b: *Client) !void {
+ for (0..64) |_| {
+ try h.pump();
+ try a.wait(5);
+ try b.wait(5);
+ while (try a.next()) |_| {}
+ while (try b.next()) |_| {}
+ if (sameScreen(a.grid.items, b.grid.items)) return;
+ }
+ return error.NeverConverged;
+ }
+
+ /// Attach a frontend and get it greeted: `open` writes the hello into the
+ /// listener's backlog, one pump accepts and answers it. Nothing blocks,
+ /// which is the whole reason the handshake is not a blocking call.
+ fn attach(h: *Harness, cols: u16, rows: u16) !Client {
+ var c = try Client.open(testing.allocator, h.name, cols, rows);
+ errdefer c.deinit();
+ _ = try h.pumpUntil(&c, .welcome);
+ return c;
+ }
+};
+
+test "detached session: a frontend attaches, is greeted, and is sent the screen" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+
+ var c = try h.attach(60, 16);
+ defer c.deinit();
+ try testing.expectEqual(@as(u8, 0), c.slot);
+ try testing.expectEqual(@as(u16, 60), c.cols);
+ try testing.expectEqual(@as(u16, 16), c.rows);
+
+ const frame = (try h.pumpUntil(&c, .frame)).frame;
+ // The first frame a frontend gets must be full: it has nothing to diff
+ // against.
+ try testing.expectEqual(wire.FrameKind.full, frame.kind);
+ try testing.expectEqual(@as(usize, 60 * 16), c.grid.items.len);
+ // ...and it must be the core's own frame, cell for cell. This is the whole
+ // claim of the transport.
+ _ = h.arena.reset(.retain_capacity);
+ const surface = try h.core.render(h.arena.allocator());
+ try expectSameScreen(surface.cells, c.grid.items);
+}
+
+test "detached session: input from a frontend reaches the core and comes back as a diff" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var c = try h.attach(60, 16);
+ defer c.deinit();
+ _ = try h.pumpUntil(&c, .frame);
+
+ // A builtin line is the cheapest input with a guaranteed visible effect,
+ // and it travels the same `Event` path a keystroke does.
+ try c.send(.{ .event = .{ .command = "Look /" } });
+ const frame = try h.pumpUntilChange(&c);
+ // The change arrived as a DIFF: this frontend was already in sync, so
+ // nothing it already had was re-sent.
+ try testing.expectEqual(wire.FrameKind.diff, frame.kind);
+ _ = h.arena.reset(.retain_capacity);
+ try expectSameScreen((try h.core.render(h.arena.allocator())).cells, c.grid.items);
+}
+
+test "detached session: two frontends share one screen at the smallest common grid" {
+ var h: Harness = undefined;
+ try h.init(80, 24);
+ defer h.deinit();
+ var a = try h.attach(80, 24);
+ defer a.deinit();
+ _ = try h.pumpUntil(&a, .frame);
+
+ // A second, smaller frontend. tmux's rule: the session shrinks to what both
+ // can show, because two people looking at different screens is the point of
+ // a shared session lost.
+ var b = try h.attach(50, 12);
+ defer b.deinit();
+ try testing.expectEqual(@as(u8, 1), b.slot);
+ try testing.expectEqual(@as(u16, 50), b.cols);
+ try testing.expectEqual(@as(u16, 12), b.rows);
+
+ // Both are now on the 50x12 grid — and getting there IS the proof that the
+ // reshape was sent as a FULL frame: `next` refuses a diff whose geometry
+ // does not match the grid it holds, so a client whose shape moved can only
+ // have been reshaped by a full one.
+ try h.pumpUntilGrid(&a, 50, 12);
+ try h.pumpUntilGrid(&b, 50, 12);
+ try testing.expectEqual(@as(usize, 50 * 12), a.grid.items.len);
+ try expectSameScreen(a.grid.items, b.grid.items);
+
+ // ...and input from EITHER moves that one screen. Both are drained on every
+ // pump before they are compared: one pump sends every attached frontend a
+ // frame, so a test that drains only one is comparing two instants.
+ try b.send(.{ .event = .{ .command = "Look /" } });
+ _ = try h.pumpUntilChange(&a);
+ try h.pumpUntilSameScreen(&a, &b);
+}
+
+test "detached session: a frontend that dies takes nothing with it" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var a = try h.attach(60, 16);
+ defer a.deinit();
+ var b = try h.attach(60, 16);
+ _ = try h.pumpUntil(&a, .frame);
+ _ = try h.pumpUntil(&b, .frame);
+ try testing.expect(h.session.clients[0].attached);
+ try testing.expect(h.session.clients[1].attached);
+
+ // Not a `bye`: the socket goes away under the session's feet, which is what
+ // a frontend crashing or being killed looks like from here.
+ b.deinit();
+ _ = try h.pumpUntil(&a, .frame);
+ try testing.expect(!h.session.clients[1].attached);
+ try testing.expectEqual(@as(c_int, -1), h.session.clients[1].fd);
+ // The survivor is still served and the core is still running.
+ try testing.expect(h.session.clients[0].attached);
+ try testing.expect(!h.core.quit);
+ try a.send(.{ .event = .{ .command = "Look /" } });
+ try testing.expect((try h.pumpUntilChange(&a)).nruns > 0);
+
+ // The freed slot takes the next frontend, and the screen comes with it.
+ var d = try h.attach(60, 16);
+ defer d.deinit();
+ try testing.expectEqual(@as(u8, 1), d.slot);
+ try testing.expectEqual(wire.FrameKind.full, (try h.pumpUntil(&d, .frame)).frame.kind);
+ try expectSameScreen(a.grid.items, d.grid.items);
+}
+
+test "detached session: a frontend speaking another protocol is refused, loudly" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+
+ // A raw socket rather than a `Client`, because the whole point is a peer
+ // that does not agree with `wire.version` — and `open` would already have
+ // sent a perfectly good hello.
+ const fd = try rawConnect(&h);
+ defer _ = libc.close(fd);
+ var buf: [64]u8 = undefined;
+ const hello = try wire.encodeClient(&buf, .{
+ .hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 },
+ });
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal));
+
+ // Two pumps: one to accept the connection, one to read the hello and
+ // answer it.
+ try h.pump();
+ try h.pump();
+ var got: [64]u8 = undefined;
+ const n = libc.read(fd, &got, got.len);
+ try testing.expect(n > 0);
+ const f = (try wire.framed(got[0..@intCast(n)])).?;
+ try testing.expectEqual(wire.Refusal.version, (try wire.decodeServer(f.tag, f.payload)).refuse);
+ // Refused means refused: the slot went back and no frame was ever sent.
+ for (&h.session.clients) |*slot| try testing.expect(!slot.attached);
+ try testing.expect(!h.core.quit);
+}
+
+test "detached session: a peer that sends garbage is dropped, not obeyed" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var c = try h.attach(60, 16);
+ defer c.deinit();
+ _ = try h.pumpUntil(&c, .frame);
+
+ // A well-formed frame around a tag this protocol has never defined. The
+ // session must close the connection rather than guess at it.
+ const junk = [_]u8{ 0xfe, 0x00, 0x00, 0x00, 0x00 };
+ try testing.expectEqual(@as(isize, junk.len), libc.send(c.fd, &junk, junk.len, nosignal));
+ for (0..8) |_| {
+ try h.pump();
+ if (!h.session.clients[0].attached) break;
+ }
+ try testing.expect(!h.session.clients[0].attached);
+ // The session is untouched: a hostile frontend costs a slot, not a session.
+ try testing.expect(!h.core.quit);
+}
+
+test "detached session: the session outlives every frontend and keeps its grid" {
+ var h: Harness = undefined;
+ try h.init(72, 20);
+ defer h.deinit();
+ {
+ var c = try h.attach(40, 10);
+ defer c.detach();
+ _ = try h.pumpUntil(&c, .frame);
+ try testing.expectEqual(@as(u16, 40), h.session.cols);
+ }
+ // Nobody attached. The grid stays where the last frontend left it rather
+ // than collapsing: a detached session is one nobody is looking at, not one
+ // of no size.
+ try h.pump();
+ try testing.expectEqual(@as(u16, 40), h.session.cols);
+ try testing.expectEqual(@as(u16, 10), h.session.rows);
+ try testing.expect(!h.core.quit);
+ // ...and the next frontend takes the grid over: with one attachment the
+ // smallest common grid IS that frontend's, so the session follows it up to
+ // 90x30 rather than pinning the departed one's 40x10 forever.
+ var again = try h.attach(90, 30);
+ defer again.deinit();
+ try testing.expectEqual(@as(u16, 90), again.cols);
+ try testing.expectEqual(@as(u16, 30), again.rows);
+ try h.pumpUntilGrid(&again, 90, 30);
+ try testing.expectEqual(@as(usize, 90 * 30), again.grid.items.len);
+}
+
+test "detached session: the seam's own routing rules, per method" {
+ var h: Harness = undefined;
+ try h.init(60, 16);
+ defer h.deinit();
+ var a = try h.attach(60, 16);
+ defer a.deinit();
+ var b = try h.attach(60, 16);
+ defer b.deinit();
+ _ = try h.pumpUntil(&a, .frame);
+ _ = try h.pumpUntil(&b, .frame);
+
+ const host = h.session.host();
+ // The eight `push_` methods with one real resource behind them go to the
+ // PRIMARY only — the oldest surviving attachment — because two frontends
+ // forking a shell for one pane gives that pane two shells.
+ host.vtable.push_spawn.?(host.ctx, 1, "/tmp");
+ try expectOnly(&h, &a, &b, .spawn);
+ host.vtable.push_pty_write.?(host.ctx, 1, "ls\n");
+ try expectOnly(&h, &a, &b, .pty_write);
+ host.vtable.push_write_file.?(host.ctx, 1, "/tmp/x", "body");
+ try expectOnly(&h, &a, &b, .write_file);
+
+ // ...and the ones that are facts about the SESSION go to everybody.
+ host.vtable.push_set_clipboard.?(host.ctx, "yank");
+ try expectBoth(&h, &a, &b, .set_clipboard);
+
+ // The one pull on the wire goes to the frontend whose input caused it, and
+ // it is asked ONCE — two frontends answering would paste twice for one
+ // Ctrl-V, which is the rule host.zig states.
+ h.session.origin = 1;
+ host.vtable.pull_read_clipboard.?(host.ctx);
+ try expectOnly(&h, &b, &a, .read_clipboard);
+ h.session.origin = 0;
+ host.vtable.push_open_link.?(host.ctx, "https://x");
+ try expectOnly(&h, &a, &b, .open_link);
+}
+
+test "detached session: the client table is a refusal, not a queue" {
+ // A small grid on purpose: these thirty-two peers never read, and a full
+ // frame of 80x24 each would push them into the backlog rule that the next
+ // test is about. 20x5 keeps every queue in the kernel's own buffer.
+ var h: Harness = undefined;
+ try h.init(20, 5);
+ defer h.deinit();
+
+ var fds: [server.max_clients]c_int = @splat(-1);
+ defer for (fds) |fd| if (fd >= 0) {
+ _ = libc.close(fd);
+ };
+ var buf: [64]u8 = undefined;
+ const hello = try wire.encodeClient(&buf, .{ .hello = .{ .cols = 20, .rows = 5 } });
+ for (&fds) |*fd| {
+ fd.* = try rawConnect(&h);
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd.*, hello.ptr, hello.len, nosignal));
+ }
+ // The listener's backlog is `max_clients` deep, so this takes a few rounds:
+ // `accept` drains what is there each time it is woken.
+ for (0..16) |_| {
+ try h.pump();
+ var attached: usize = 0;
+ for (&h.session.clients) |*slot| if (slot.attached) {
+ attached += 1;
+ };
+ if (attached == server.max_clients) break;
+ }
+ for (&h.session.clients) |*slot| try testing.expect(slot.attached);
+
+ // The thirty-third is TOLD it does not fit, and told promptly: the
+ // alternative — leaving it in the backlog — makes a level-triggered poll
+ // report the listener ready forever and spins the core.
+ const extra = try rawConnect(&h);
+ defer _ = libc.close(extra);
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(extra, hello.ptr, hello.len, nosignal));
+ var got: [64]u8 = undefined;
+ const refusal = for (0..8) |_| {
+ try h.pump();
+ const n = libc.read(extra, &got, got.len);
+ if (n > 0) break got[0..@intCast(n)];
+ } else return error.NeverRefused;
+ const f = (try wire.framed(refusal)).?;
+ try testing.expectEqual(wire.Refusal.full, (try wire.decodeServer(f.tag, f.payload)).refuse);
+ // ...and the thirty-two it does serve are untouched.
+ for (&h.session.clients) |*slot| try testing.expect(slot.attached);
+ try testing.expect(!h.core.quit);
+}
+
+test "detached session: a frontend that stops reading is dropped, not waited for" {
+ var h: Harness = undefined;
+ try h.init(40, 10);
+ defer h.deinit();
+ var good = try h.attach(40, 10);
+ defer good.deinit();
+
+ // A peer that says hello and then never reads a byte again — a frontend
+ // stopped in a debugger, or one whose terminal is blocked.
+ const mute = try rawConnect(&h);
+ defer _ = libc.close(mute);
+ var buf: [64]u8 = undefined;
+ const hello = try wire.encodeClient(&buf, .{ .hello = .{ .cols = 40, .rows = 10 } });
+ try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(mute, hello.ptr, hello.len, nosignal));
+ for (0..8) |_| {
+ try h.pump();
+ if (h.session.clients[1].attached) break;
+ }
+ try testing.expect(h.session.clients[1].attached);
+
+ // Broadcast enough control traffic to pass `out_backlog`. A clipboard
+ // mirror is the honest vehicle: it is a real `push_` that reaches every
+ // frontend and carries the yank register, so this is a session yanking a
+ // lot rather than a synthetic poke.
+ const text = try testing.allocator.alloc(u8, 256 * 1024);
+ defer testing.allocator.free(text);
+ @memset(text, 'y');
+ const host = h.session.host();
+ for (0..24) |_| {
+ if (!h.session.clients[1].attached) break;
+ host.vtable.push_set_clipboard.?(host.ctx, text);
+ try h.pump();
+ try good.wait(5);
+ while (try good.next()) |_| {}
+ }
+ // Dropped rather than queued without bound, and rather than the core
+ // blocking on it.
+ try testing.expect(!h.session.clients[1].attached);
+ try testing.expectEqual(@as(c_int, -1), h.session.clients[1].fd);
+ // The frontend that WAS reading is still attached and still being drawn
+ // for, which is the whole claim: one slow peer costs its own slot.
+ try testing.expect(h.session.clients[0].attached);
+ try testing.expect(!h.core.quit);
+ try good.send(.{ .event = .{ .command = "Msg still here" } });
+ try testing.expect((try h.pumpUntilChange(&good)).nruns > 0);
+}
+
+/// A connected socket with nothing said on it yet, for the tests whose peer is
+/// deliberately not a `Client`: one that speaks another protocol, thirty-two
+/// that fill the table, one that never reads.
+fn rawConnect(h: *Harness) !c_int {
+ var path_buf: [server.path_max]u8 = undefined;
+ const path = server.sessionPath(&path_buf, h.name).?;
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
+ if (fd < 0) return error.NoSocket;
+ errdefer _ = libc.close(fd);
+ if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.NoSession;
+ return fd;
+}
+
+/// `want` reached `to` and nothing reached `other`.
+fn expectOnly(
+ h: *Harness,
+ to: *Client,
+ other: *Client,
+ comptime want: std.meta.Tag(wire.ServerMsg),
+) !void {
+ _ = try h.pumpUntil(to, want);
+ try other.wait(5);
+ while (try other.next()) |msg| if (std.meta.activeTag(msg) == want) {
+ std.debug.print("{t} reached a frontend it was not routed to\n", .{want});
+ return error.Misrouted;
+ };
+}
+
+fn expectBoth(
+ h: *Harness,
+ a: *Client,
+ b: *Client,
+ comptime want: std.meta.Tag(wire.ServerMsg),
+) !void {
+ _ = try h.pumpUntil(a, want);
+ _ = try h.pumpUntil(b, want);
+}
+
+/// Are these two grids showing the same thing? `visuallyEqual` and not
+/// `std.meta.eql`, because `Cell.text` past `len` is scratch the decoder does
+/// not invent — pardes.zig says in as many words that it must never
+/// manufacture a difference.
+fn sameScreen(want: []const pardes.Cell, have: []const pardes.Cell) bool {
+ if (want.len != have.len) return false;
+ for (want, have) |*x, *y| if (!x.visuallyEqual(y)) return false;
+ return true;
+}
+
+fn expectSameScreen(want: []const pardes.Cell, have: []const pardes.Cell) !void {
+ try testing.expectEqual(want.len, have.len);
+ for (want, have, 0..) |*x, *y, i| if (!x.visuallyEqual(y)) {
+ std.debug.print("cell {d} differs\n", .{i});
+ return error.CellMismatch;
+ };
+}