diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 18:58:37 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 09:47:39 -0300 |
| commit | 29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch) | |
| tree | 6629cc215d6953090f6b29a7414b28cb9990e105 /src/detached/client.zig | |
| parent | 11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff) | |
| download | pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip | |
An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring
## A terminal row's ANSI colours survive being edited
The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY
column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell
row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape
is invisible: append past the pane's right edge, where the text is clipped, and the row looks the
same and only its colour goes.
Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same
bytes they keep the same colours; only what was typed has no cell under it, so only that takes none.
Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character.
Three defects underneath it, all found by machinery rather than by reading:
* A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both
aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom —
resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a
streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per
buffer line, linear in the buffer where the version before it was quadratic.
* An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span,
and left free to look ahead it claimed the blank row below the last output and took every coloured
row in between out of reach of the lines that owned them.
* Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin
verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin
into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired
the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped
back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own
renderer draws.
Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the
raw path resolved a `.none` colour by role and never consulted the mode.
The test that found the first two is the one worth keeping: random editing against an ABSOLUTE
oracle — every row's own text names the colour it must have — because the differential oracle it
replaced was blind by construction. It skipped the edited row, which is the row the user is
complaining about.
## Esc returns to a pane without moving its view
Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through
`focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer
repainted the whole screen to show a line that was already on it.
`focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not
been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves
the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the
minimum into the `scroll_off` band — be the only thing that may move anything.
Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background
pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a
resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back.
Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only
ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy`
can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with
`scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist
centres, its buffer switch does not.
One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal
IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to
that page's start, discarding where you had read to. When something moved the pane while you were
away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the
recorded page.
## host_io.zig: the machine-local half of a host, once
`host.zig` is the seam. The part of the answer that is identical on every host with an operating
system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig,
gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for:
all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program
in one pane could read another pane's terminal.
One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the
server to fork anything — the server has an operating system under it and forks through `host_io`
like every other host — and `decodeClient` lost the scratch buffer that message needed.
Diffstat (limited to 'src/detached/client.zig')
| -rw-r--r-- | src/detached/client.zig | 405 |
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 |
