summaryrefslogtreecommitdiff
path: root/src/detached/client.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/detached/client.zig')
-rw-r--r--src/detached/client.zig405
1 files changed, 359 insertions, 46 deletions
diff --git a/src/detached/client.zig b/src/detached/client.zig
index 8d118782..07c85945 100644
--- a/src/detached/client.zig
+++ b/src/detached/client.zig
@@ -1,11 +1,21 @@
//! 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.
+//! A FRONTEND IS INPUT AND SCREEN, and that is the whole of it. Keystrokes,
+//! mouse, window size go out; frames come back and get painted. This is the
+//! shape the ESP32-P4 serial console has always had — a panel and a keypad on
+//! the far end of a wire, performing no effects of its own — and after the
+//! machine-local IO moved into the daemon it is the shape EVERY frontend has,
+//! terminal and window alike.
+//!
+//! THIS SIDE DOES NOT OWN A `Pardes`. 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 now lands: forking a pane's
+//! shell, writing a file, watching a path and reading the theme directory are
+//! all done BY THE DAEMON, against the same machine's kernel it shares with
+//! this frontend over an AF_UNIX socket. NO FORK HAPPENS IN A FRONTEND ANY
+//! MORE. That is not a simplification of this file, it is the fix: a pane's
+//! shell used to be a child of whichever frontend forked it, so leaving killed
+//! the shells of a session whose entire promise is outliving frontends.
//!
//! `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
@@ -20,23 +30,22 @@
//! 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:
+//! WHAT ARRIVES. `next` hands back one decoded `wire.ServerMsg` at a time, and
+//! there are exactly seven of them:
//! * `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
+//! * `set_clipboard`, `read_clipboard` and `open_link` are the only effects
+//! still on the wire, and they are here because each needs THIS HUMAN'S
+//! DISPLAY: a daemon nobody is looking at has no clipboard and no browser.
+//! 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.
+//! The switch in `next` is exhaustive over that set on purpose: putting a
+//! machine-local effect back on the wire is a compile error here, and the test
+//! at the bottom of this file says so in the other direction too.
//!
//! 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
@@ -45,8 +54,8 @@
//!
//! 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.
+//! `wait`. A frontend that needs 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");
@@ -104,6 +113,15 @@ pub const Client = struct {
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;
+ // `vetted` is one predicate over two different failures, and a human is
+ // owed different words for them: `NotPrivate` promises, in its own doc
+ // above, that the socket IS there. So ask the cheap question first,
+ // because the commonest failure of all is a mistyped session name —
+ // until this, `Attach nosuchsession` put "attach: NotPrivate" on the
+ // message row, which reads as an accusation rather than a typo. Spelled
+ // with the `access` this file already uses in `resolve`.
+ const F_OK: c_int = 0;
+ if (libc.access(path, F_OK) != 0) return error.NoSession;
// 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.
@@ -113,10 +131,10 @@ pub const Client = struct {
@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.
+ // CLOEXEC anyway, even though a frontend no longer forks pane shells:
+ // `open_link` runs this display's browser, and a `xdg-open` inheriting
+ // 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
@@ -166,8 +184,11 @@ pub const Client = struct {
}
/// 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`.
+ /// through here, and after the IO moved into the daemon that is a short
+ /// list: keys, the mouse, a window resize, and the paste that answers a
+ /// `read_clipboard`. `ClientMsg.output`/`.eof` still exist on the wire but
+ /// no frontend sends them any more — pty bytes are read by the process that
+ /// forked the shell, which is the daemon.
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;
@@ -266,7 +287,28 @@ pub const Client = struct {
// 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 => {},
+ // THIS frontend is done and the session is not. Same shape as
+ // `quit` and the same non-answer here — the caller leaves its loop
+ // — but the opposite meaning about what survives, so a frontend
+ // must not collapse the two: `quit` is the session ending and
+ // `detach` is success. Guarded, because a peer that has not greeted
+ // us has no standing to dismiss us either.
+ .detach => if (!c.attached()) return error.Ungreeted,
+ // The three display effects need this process's clipboard and this
+ // process's browser, so they are the caller's to perform and there
+ // is nothing for a `Client` to update. Listed rather than swept up
+ // by an `else`: an `else` here would silently accept a
+ // machine-local effect returning to the wire, and the point of the
+ // rewrite is that it cannot.
+ //
+ // Guarded like `.frame`, and for a stronger reason than drawing:
+ // these reach the human's clipboard and the human's browser. The
+ // protocol version is checked in the `.welcome` arm above and
+ // nowhere else, so a peer that simply never greets us has had its
+ // version checked by nobody — and until it does, it does not get to
+ // open a URI on this display or read this display's selection back
+ // over the socket. `Ungreeted` is the same refusal a frame gets.
+ .set_clipboard, .read_clipboard, .open_link => if (!c.attached()) return error.Ungreeted,
}
return msg;
}
@@ -337,6 +379,201 @@ const poll_hup = server.poll_hup;
const poll_err = server.poll_err;
const poll_nval = server.poll_nval;
+/// Which session an attach meant, answered before a frontend opens its window.
+///
+/// `--attach=<name>` is taken at its word beyond one `access` on the socket
+/// file, because the connect is the real authority on whether anything is
+/// listening — and a typo is the one failure worth catching earlier, since the
+/// alternative is a full-screen flash on the way to a one-line message. Bare
+/// `--attach`, and the bare `Attach` word, is THE session: bare `--detach`
+/// names itself by its own pid, and nobody can be expected to read a pid out
+/// of `$XDG_RUNTIME_DIR`. With exactly one listening, that is the one meant;
+/// with none or several this says WHICH case it is instead of picking one.
+///
+/// Both the directory and the filename convention come off ONE probe through
+/// `server.sessionPath` rather than being re-derived here, for the reason that
+/// function exists at all: the side that binds and the side that looks must
+/// never be able to disagree about where a session lives.
+///
+/// It lives in this file, rather than in either shell, because BOTH frontends
+/// now ask the same question — a terminal for `--attach` and the SDL window
+/// for the `Attach` word — and a second copy of a directory scan is exactly
+/// how the two of them would start disagreeing.
+pub const Resolved = union(enum) {
+ /// The session to open. Borrows `requested` when it was named, and `buf`
+ /// when it had to be scanned for.
+ name: []const u8,
+ /// No socket of that name, or no session at all. The caller knows which it
+ /// asked for, so it owns the wording.
+ none,
+ /// Several are listening, and choosing between them is not ours to do.
+ ambiguous: usize,
+};
+
+pub fn resolve(buf: *[server.path_max]u8, requested: []const u8) Resolved {
+ if (requested.len != 0) {
+ var one_buf: [server.path_max]u8 = undefined;
+ const one = server.sessionPath(&one_buf, requested) orelse return .none;
+ const F_OK: c_int = 0;
+ if (libc.access(one, F_OK) != 0) return .none;
+ return .{ .name = requested };
+ }
+ const probe_name = "0";
+ var probe_buf: [server.path_max]u8 = undefined;
+ const probe = server.sessionPath(&probe_buf, probe_name) orelse return .none;
+ const base = std.fs.path.basename(probe);
+ const cut = std.mem.lastIndexOf(u8, base, probe_name).?;
+ const prefix = base[0..cut];
+ const suffix = base[cut + probe_name.len ..];
+ var dir_buf: [server.path_max:0]u8 = undefined;
+ const dir = std.fs.path.dirname(probe) orelse "";
+ if (dir.len == 0 or dir.len >= dir_buf.len) return .none;
+ @memcpy(dir_buf[0..dir.len], dir);
+ dir_buf[dir.len] = 0;
+ const d = libc.opendir(dir_buf[0..dir.len :0]) orelse return .none;
+ defer _ = libc.closedir(d);
+ var found: usize = 0;
+ var len: usize = 0;
+ while (libc.readdir(d)) |ent| {
+ const entry = std.mem.sliceTo(&ent.name, 0);
+ if (entry.len <= prefix.len + suffix.len) continue;
+ if (!std.mem.startsWith(u8, entry, prefix) or !std.mem.endsWith(u8, entry, suffix)) continue;
+ const name = entry[prefix.len .. entry.len - suffix.len];
+ if (name.len > buf.len) continue;
+ found += 1;
+ @memcpy(buf[0..name.len], name);
+ len = name.len;
+ }
+ // A socket file whose session is gone still counts here: the sweep that
+ // unlinks corpses runs when the NEXT session binds (server.zig `sweep`),
+ // and probing every candidate with a connect would put a phantom frontend
+ // into a live session's slot table just to count it. One stale file
+ // therefore fails at `open` with "no such session", which is the truth.
+ if (found != 1) return if (found == 0) .none else .{ .ambiguous = found };
+ return .{ .name = buf[0..len] };
+}
+
+/// How long a frontend's attached loop waits on the socket before it goes back
+/// to whatever else it owns. It lives HERE, beside the `wait` it parameterises,
+/// because both frontends need it and both had defined it for themselves —
+/// which is how a measured number drifts from the thing it was measured
+/// against.
+///
+/// A frontend cannot hand `poll(2)` one descriptor for the session and one for
+/// its own input: vaxis delivers the terminal's events on a reader thread into
+/// a mutex/condvar queue, and SDL has its own pump, so neither has a
+/// descriptor. `wait` takes a timeout for exactly that reason.
+///
+/// 8 ms is half a 60 Hz frame: a keystroke waits at most one of those before it
+/// is on the wire (4 ms on average), and the frame it causes needs no wait at
+/// all — it lands in the poll the moment the session writes it. The price is
+/// 125 poll rounds a second on a frontend nobody is touching, measured below
+/// the noise of what an idle pardes already costs: on an i7-11700 at 100 Hz
+/// jiffies an idle attached frontend used 0.16% of one core over 60 s and 0.18%
+/// over 120 s, against 0.11% and 0.31% for an idle in-process session on the
+/// same screen over the same windows. Reach for an eventfd and a waker thread —
+/// fuse.zig's `pollLoop` is the pattern — only if that stops being true.
+pub const poll_ms: u32 = 8;
+
+/// One round of waiting for the greeting, and how many of them. The COUNT is
+/// derived from `server.greet_deadline_default_ms` rather than written down
+/// again, so the two ends of the handshake give each other the same grace out
+/// of one number instead of two that can drift apart.
+///
+/// Rounds rather than a clock, and that is sound rather than lazy: the only
+/// message a session may legally send before its `welcome` is a `refuse`.
+/// Everything else is refused by `next` as `Ungreeted` — a frame because it
+/// would be drawing for a connection that was never accepted, and the three
+/// display effects because a peer whose version nobody has checked does not get
+/// to open a URI or read a clipboard. So a peer that is silent costs one whole
+/// timeout per round, which makes the round count a real wall-clock bound; and
+/// a peer that is NOISY but ungreeted fails on its first message rather than
+/// spending the budget.
+const greet_round_ms: u32 = 250;
+const greet_rounds: u32 = server.greet_deadline_default_ms / greet_round_ms;
+
+/// What came of trying to attach. A VALUE and not an error union, because four
+/// of the six outcomes are ordinary answers a human needs different words for,
+/// and because the two frontends report them by different mechanisms — a
+/// terminal prints a sentence and exits, a window logs and unwinds. `resolve`
+/// above returns a union for the same reason.
+pub const Attempt = union(enum) {
+ /// Resolved, connected, AND greeted. The caller owns it.
+ greeted: Client,
+ /// No socket of that name, or nothing detached at all. The caller knows
+ /// which it asked for, so the wording is the caller's.
+ no_session,
+ /// Several are listening and choosing is not ours to do.
+ ambiguous: usize,
+ /// The session said no, and said why.
+ refused: wire.Refusal,
+ /// It accepted and then never greeted us inside the deadline.
+ silent,
+ /// Everything else, already reported by the errno it came from.
+ lost: anyerror,
+};
+
+/// Resolve a name, connect to it, and WAIT FOR THE WELCOME. On every failure
+/// path this closes whatever it opened, so a caller that gets anything but
+/// `.greeted` has nothing to clean up.
+///
+/// The waiting is the point, and it is why this function exists rather than
+/// each frontend calling `resolve` and `open` in turn. `open` is not a
+/// handshake — it connects and writes the hello, and the `welcome` or the
+/// `refuse` arrives later through this loop. A frontend that treats a
+/// successful `connect(2)` as proof of attachment will tear its local session
+/// down — reap its pane shells, unmount its control filesystem, free every
+/// undo history — and only then discover `refuse .version`, which is the
+/// routine case: `zig build` replaces the binary under a running session, so
+/// two protocol versions on one machine is expected rather than exotic. The
+/// contract the `Attach` word owes is that a failed attach changes NOTHING, and
+/// that contract can only be kept by a caller that has the welcome in hand
+/// before it starts destroying things.
+pub fn attempt(gpa: std.mem.Allocator, requested: []const u8, cols: u16, rows: u16) Attempt {
+ var buf: [server.path_max]u8 = undefined;
+ const name = switch (resolve(&buf, requested)) {
+ .name => |n| n,
+ .none => return .no_session,
+ .ambiguous => |n| return .{ .ambiguous = n },
+ };
+ var c = Client.open(gpa, name, cols, rows) catch |err| return switch (err) {
+ // `open`'s own two ways of saying "there is nothing there" collapse
+ // into the one the caller has wording for.
+ error.NoSession, error.NoSessionPath => .no_session,
+ else => .{ .lost = err },
+ };
+ var rounds: u32 = 0;
+ while (rounds < greet_rounds) : (rounds += 1) {
+ c.wait(greet_round_ms) catch |err| return give(&c, err);
+ // Drain whatever landed. `next` is what applies the welcome, so the
+ // loop below is not discarding anything it needs — the state it wants
+ // is in `c` afterwards.
+ while (true) {
+ const msg = c.next() catch |err| return give(&c, err);
+ if (msg == null) break;
+ }
+ if (c.attached()) return .{ .greeted = c };
+ if (c.refusal) |why| {
+ c.deinit();
+ return .{ .refused = why };
+ }
+ }
+ c.deinit();
+ return .silent;
+}
+
+/// Close, and say what the failure actually was. A session that refuses us
+/// writes its reason and closes in the same pass (server.zig `refuseFd`), so a
+/// read error here is usually the far side hanging up on a refusal we have
+/// already decoded — reporting that as a lost connection would throw away the
+/// one sentence worth telling the human.
+fn give(c: *Client, err: anyerror) Attempt {
+ const why = c.refusal;
+ c.deinit();
+ if (why) |w| return .{ .refused = w };
+ return .{ .lost = err };
+}
+
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -346,7 +583,6 @@ const poll_nval = server.poll_nval;
// 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;
@@ -382,12 +618,17 @@ const Harness = struct {
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 };
+ // `io` is not optional on `Session`: the daemon does the file watching
+ // and the theme scan itself now, and both of those take a `std.Io`.
+ h.session = .{ .gpa = testing.allocator, .io = std.testing.io, .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.
+ // have not been created. These now fork IN THIS PROCESS, because the
+ // daemon is the thing that owns pane shells — which is exactly the
+ // property under test, and the reason no frontend below is ever asked
+ // to fork anything.
h.core.host = h.session.host();
while (h.core.nextEffect()) |e| h.core.perform(e);
}
@@ -553,7 +794,13 @@ test "detached session: two frontends share one screen at the smallest common gr
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);
+ // CONVERGENCE, not a snapshot. `pumpUntilGrid(&b, ...)` pumped the session
+ // while draining only `b`, and the daemon owns the pane shells now: a
+ // prompt arriving on a pty moves the screen between pumps, so `a` can be
+ // holding an undrained frame and comparing the two grids here would compare
+ // two instants. What a shared session promises is that they agree, which is
+ // what this waits for.
+ try h.pumpUntilSameScreen(&a, &b);
// ...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
@@ -592,7 +839,11 @@ test "detached session: a frontend that dies takes nothing with it" {
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);
+ // ...and it is the SAME screen the survivor is looking at. Waited for
+ // rather than snapshotted, for the reason above: `pumpUntil(&d, ...)`
+ // drained only `d`, and pane output means the screen does not stand still
+ // between pumps.
+ try h.pumpUntilSameScreen(&a, &d);
}
test "detached session: a frontend speaking another protocol is refused, loudly" {
@@ -674,7 +925,7 @@ test "detached session: the session outlives every frontend and keeps its grid"
try testing.expectEqual(@as(usize, 90 * 30), again.grid.items.len);
}
-test "detached session: the seam's own routing rules, per method" {
+test "detached session: the seam's own routing rules, per surviving effect" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
@@ -686,31 +937,93 @@ test "detached session: the seam's own routing rules, per method" {
_ = 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.
+ // BROADCAST: the yank register is a fact about the session, so every
+ // display it is being watched on gets it.
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.
+ // ORIGIN, ELSE PRIMARY. This is the "only one frontend is asked" rule that
+ // the shell-forking and file-writing messages used to demonstrate; the
+ // daemon does that work itself now, so the same claim is made about the two
+ // effects that still travel. `read_clipboard` 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;
+ // `open_link` follows the same origin: the browser that opens is the one on
+ // the display of the human who clicked, not the oldest attachment's.
host.vtable.push_open_link.?(host.ctx, "https://x");
+ try expectOnly(&h, &b, &a, .open_link);
+ // ...and with no origin it falls back to the primary, which is what a link
+ // opened by something other than a keystroke gets.
+ h.session.origin = 0;
+ host.vtable.push_open_link.?(host.ctx, "https://y");
try expectOnly(&h, &a, &b, .open_link);
}
+test "detached session: a frontend is never asked to fork, write, or watch" {
+ // THE INVARIANT OF THE WHOLE DETACHED DESIGN, pinned as a property of the
+ // protocol rather than of one code path: a frontend is input and screen, so
+ // the set of messages that can reach it is exactly the eight below. If a
+ // machine-local effect is ever put back on the wire, a frontend becomes the
+ // process that owns a pane's shell again — and a pane whose shell belongs to
+ // a frontend dies when that frontend leaves, which is the bug this replaced.
+ //
+ // Adding a name here is meant to be an ARGUMENT, not a formality. The bar is
+ // the one `quit` and `detach` clear and `spawn` cannot: it needs this
+ // human's screen, keyboard, clipboard or browser, or it is the session
+ // telling this frontend about its own membership. Anything that touches a
+ // disk or a process table fails that bar by construction.
+ const allowed = [_][]const u8{
+ // Session control: who this connection is, and whether it is still one.
+ // `detach` is this frontend leaving and `quit` is the session ending —
+ // opposite meanings, same shape, and neither is an effect.
+ "welcome", "refuse", "frame", "quit", "detach",
+ // The three that need THIS human's display and cannot be done by a
+ // daemon nobody is looking at.
+ "set_clipboard", "read_clipboard", "open_link",
+ };
+
+ // One: the union a frontend decodes into has no other variant. Named
+ // rather than counted, so re-adding `spawn` fails with the name in it.
+ const fields = @typeInfo(wire.ServerMsg).@"union".fields;
+ inline for (fields) |f| {
+ for (allowed) |ok| {
+ if (std.mem.eql(u8, f.name, ok)) break;
+ } else {
+ std.debug.print("ServerMsg.{s} is not an effect a frontend may perform\n", .{f.name});
+ return error.MachineLocalEffectOnTheWire;
+ }
+ }
+ try testing.expectEqual(allowed.len, fields.len);
+
+ // Two: and no TAG BYTE outside them decodes either — the check above is
+ // about this build's union, this one is about the bytes on the socket. Every
+ // other byte must be `BadTag`, including the eight that used to be defined:
+ // a session built before this change cannot talk a frontend into forking.
+ var accepted: usize = 0;
+ for (0..256) |i| {
+ const tag: u8 = @intCast(i);
+ // Payloads are deliberately empty: what is asked is whether the TAG is
+ // known, and every known tag fails later (`Truncated`) or succeeds, but
+ // never with `BadTag`.
+ if (wire.decodeServer(tag, &.{})) |_| accepted += 1 else |err| switch (err) {
+ error.BadTag => continue,
+ else => accepted += 1,
+ }
+ const named = for (allowed) |ok| {
+ const want = std.meta.stringToEnum(wire.ServerTag, ok).?;
+ if (@intFromEnum(want) == tag) break true;
+ } else false;
+ if (!named) {
+ std.debug.print("tag 0x{x:0>2} is decodable by a frontend and is not one of the seven\n", .{tag});
+ return error.MachineLocalEffectOnTheWire;
+ }
+ }
+ try testing.expectEqual(allowed.len, accepted);
+}
+
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