diff options
Diffstat (limited to 'src/detached')
| -rw-r--r-- | src/detached/client.zig | 405 | ||||
| -rw-r--r-- | src/detached/server.zig | 1169 | ||||
| -rw-r--r-- | src/detached/wire.zig | 464 |
3 files changed, 1495 insertions, 543 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 diff --git a/src/detached/server.zig b/src/detached/server.zig index 6d13a4ae..93c964b4 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1,53 +1,96 @@ //! THE DETACHED CORE: one `Pardes` instance in a process with no terminal, //! serving N frontends over one unix socket. //! -//! THIS SIDE OWNS THE CORE. `Session` is a `host.Host` implementation whose -//! methods encode wire messages instead of doing IO, and whose -//! `pull_wait_input` is a `poll(2)` over the listener and every attached -//! frontend. The frontends own terminals and nothing else (client.zig). So the -//! `Pardes` is here, `update` is called from here, and the same screen is on -//! every attached frontend at once — `screen -x`, not N sessions. +//! THIS SIDE OWNS THE CORE, AND EVERYTHING UNDER IT. `Session` is a `host.Host` +//! implementation that performs the machine-local half of a host itself — it +//! forks the pane shells, writes the files, watches the paths — and whose +//! `pull_wait_input` is one `poll(2)` over the listener, every attached +//! frontend, every pane's pty master and the inotify descriptor. A frontend owns +//! a screen and a keyboard and nothing else (client.zig). So the `Pardes` is +//! here, `update` is called from here, the shells are forked from here, and the +//! same screen is on every attached frontend at once — `screen -x`, not N +//! sessions. //! -//! WHAT THIS SIDE SERVES ITSELF. Every method this vtable leaves null falls -//! through to the core's own `host.Fallback`: the embedded source filesystem, -//! the in-process clipboard, silent ptys. host.zig says in as many words that a -//! zero-method host is a complete pardes, and that is exactly what a session -//! with nothing attached is. Everything a real frontend can do BETTER — fork a -//! shell on a real tty, put bytes on a real disk, reach a real desktop -//! clipboard — is asked of a frontend, and the routing table below says which. +//! WHAT THIS SIDE SERVES ITSELF, WHICH IS NOW ALL OF IT. A unix socket means +//! the core and its frontends are on the SAME machine, so there is no question +//! of whose process table, whose disk or whose inotify descriptor a call is +//! about — and given that, the process that must hold them is the long-lived +//! one. A shell forked by a frontend dies with that frontend, and a session +//! whose whole promise is outliving the frontend attached to it cannot keep its +//! panes that way. So this file forks the pane shells (`host_io.forkShell`), +//! writes the files (`host_io.writeFileBytes`), marks the directories +//! (file_watch.zig) and drains the pty masters in its own `poll(2)`. THE PANE +//! SHELLS OUTLIVE EVERY FRONTEND: attach, detach, kill the terminal, attach +//! from another one, and the build that was running in pane 3 is still running +//! and has been scrolling into the core the whole time. +//! +//! Only what this vtable leaves null falls through to the core's own +//! `host.Fallback` — and host.zig says in as many words that a zero-method host +//! is a complete pardes. What a frontend can still do BETTER is exactly what +//! needs the human's own display, and nothing else: put a yank on the clipboard +//! in front of them, take a paste off it, open a link in their browser. Three +//! messages, which is why the routing table below is as short as it is. //! //! ROUTING, and it is not "push means broadcast". A push reaches every HOST //! (host.zig's rule, which `Fanout.isPull` enforces); this is ONE host that //! happens to be backed by several frontends, and how it spreads a call inside -//! itself is its own business. Three rules, one per kind of side effect: +//! itself is its own business. Two rules over four messages: //! * BROADCAST — the frame, and `set_clipboard`. Every screen must show the //! same thing, and a yank in a shared session is a session-wide fact that //! every attached desktop is entitled to. -//! * PRIMARY ONLY — `spawn`, `pty_write`, `pty_resize`, `write_file`, -//! `write_dump`, `watch_file`, `watch_theme`, `dump_themes`. Each of these -//! has ONE real resource behind it, and doing it twice is not doing it -//! twice as well: two frontends forking a shell for pane 3 gives the pane -//! two shells, and two frontends writing one path race each other. Primary -//! is the lowest attached slot, i.e. the oldest surviving attachment — a -//! rule that is stable while frontends come and go and needs no election. -//! A pane's shell therefore lives in the frontend that forked it: when that -//! frontend leaves, its panes stop producing output and the session's text, -//! files and layout carry on. That is a real limit and it is stated here -//! rather than papered over, because migrating a live pty between processes -//! is a different feature. -//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire) -//! and `open_link`. Both answer a thing a HUMAN just did, and the answer -//! belongs on that human's machine: the paste must come from the keyboard -//! that asked for it, and a link must open in front of the person who -//! clicked it. `origin` is the frontend whose event was applied most -//! recently. Effects drain after a whole batch of events (pardes.zig +//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire), +//! `open_link`, and `detach`. Each answers a thing a HUMAN just did, and the +//! answer belongs to that human: the paste must come from the keyboard that +//! asked for it, a link must open in front of the person who clicked it, and +//! a `Detach` typed in one frontend must send THAT frontend away and leave +//! the others painting. `origin` is the frontend whose event was applied +//! most recently. Effects drain after a whole batch of events (pardes.zig //! `pump`), so in the rare case where two frontends type in the same -//! millisecond the second one wins; the fallback to primary covers an +//! millisecond the second one wins; the fallback to `primary` — the lowest +//! attached slot, i.e. the oldest surviving attachment, a rule that is +//! stable while frontends come and go and needs no election — covers an //! effect that no input caused at all. //! -//! FAIRNESS, and why no client can stall the core or another client: -//! * every descriptor is non-blocking, and there is no thread per client. One -//! `poll(2)` per pump covers the listener and all `max_clients` frontends. +//! `detach` is the odd one and is worth naming as such: it is not an EFFECT the +//! session performs on the world, it is SESSION CONTROL — one frontend asking to +//! stop being a frontend. That is why wire.zig gives it 0x05, in the +//! 0x01..0x0f session range beside `quit`, rather than a number in the 0x10.. +//! range where every tag is one `push_` method that reaches a disk, a clipboard +//! or a browser. And it is why this side does nothing but send it: see `detach`. +//! There is no third rule, and the class of message it used to serve is gone: +//! `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`, `watch_file`, +//! `watch_theme` and `dump_themes` were routed to ONE frontend precisely +//! because each has one real resource behind it, and every one of them is now +//! performed HERE, once, by the process that owns the resource. Two frontends +//! can no longer fork two shells for pane 3 or race each other writing one +//! path, because neither of them writes anything. +//! +//! FAIRNESS, and why no client — and no shell — can stall the core or another +//! client. The property the bullets below add up to is worth stating as one +//! sentence, because it is what a detached session is FOR: there is no path on +//! which this process blocks indefinitely. Every descriptor it holds is +//! non-blocking, the single `poll(2)` is the only place it sleeps, and every +//! queue that could grow without bound has a ceiling with a stated answer for +//! reaching it. A daemon nobody is looking at cannot be made to stop looking +//! after the shells nobody else is keeping. +//! * ONE `poll(2)` per pump covers the listener, all `max_clients` frontends, +//! all `pardes.MAX_PANES` pty masters and the inotify descriptor: +//! `poll_slots` descriptors, one syscall, no thread per client and none per +//! pty. Putting the shells in the poll set the clients were already in is +//! what lets a daemon own sixteen of them and stay single-threaded. +//! * one read per pty per round, which is `receive`'s rule for clients +//! applied to shells: a `yes` in pane 1 gets one turn and the loop moves on +//! to the other panes, the frontends and the frame. +//! * EVERY descriptor is non-blocking, sockets and pty masters alike, and a +//! pane owes its bytes the same way a client does. A blocking write to a +//! master was the one hole this file's own comment used to argue was safe — +//! "the peer on a pty is a shell this process forked, not a stranger who can +//! stop reading on purpose" — and that was wrong, because the peer is +//! whatever program the human ran in that pane. `sleep 3600` plus a paste +//! larger than the pty's input buffer parked the WHOLE daemon inside +//! `write(2)`: no frame to any frontend, fifteen other masters unread, no +//! `accept`, no `expire`, no inotify drain. So a pane has an out-queue and a +//! POLLOUT, on the descriptor that was already in the set. See `ptyWrite`. //! * FRAMES ARE NOT QUEUED. A client with bytes still owed to the kernel is //! SKIPPED for this frame and its mirror is left alone, so the next frame //! it does get is a diff against what it actually has. A slow frontend @@ -96,11 +139,48 @@ //! collects, so the client checks the directory and the socket before it //! connects, exactly as this side checks them before it binds. const std = @import("std"); +const builtin = @import("builtin"); const libc = std.c; +const linux = std.os.linux; +const posix = std.posix; const pardes = @import("../pardes.zig"); const host_api = @import("../host.zig"); const wire = @import("wire.zig"); +/// The machine-local half of a host — fork a shell onto a pty, put bytes on a +/// disk — shared verbatim with the tty shell, and the sharing is the point: +/// `spawn` and `writeFile` below are the same two operations tty.zig performs, +/// and having them in one file is what keeps a daemon's pane and a terminal's +/// pane the same pane. See host_io.zig's header for why the daemon is the side +/// that performs them. +const host_io = @import("../host_io.zig"); + +/// ...and the inotify half, likewise shared: `applyEffect` is the mark-then- +/// reconcile transaction the tty and sdl shells run, and this session runs the +/// identical one. All that differs is who waits on the descriptor — a thread +/// there, `waitInput`'s poll set here. +const file_watch = @import("../file_watch.zig"); + +/// Host-lifetime storage for the OSC 133 rc files a forked shell sources, held +/// by `Session` because a Session is exactly one host's lifetime. +const shell_bin = @import("../shell_bin.zig"); + +/// `shellCwd` for a pane's shell, `ttyTaken` for a pane the core is about to +/// type a command line into, `readFile` for `run`'s `--load`. +const look = @import("../look.zig"); + +/// The "saved <path>" / "dumped themes <path>" message row, stamped the way +/// every other host stamps it — one clock format across every frontend. +const message = @import("../message.zig"); + +/// `Dump themes` writes the reference set out as .zon, into the core's own +/// `opts.config_dir`. +const user_config = @import("../user_config.zig"); + +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). The +// same constant the tty, gui and macos shells spell, for the same reason. +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + /// Diagnostics for whoever is running the daemon. Every one of these is a /// `debug`, and the level is not a judgement about how bad the thing is: /// main.zig's logFn drops this scope entirely unless PARDES_LOG is set, so what @@ -136,11 +216,11 @@ const sun_path_len = nested.sun_path_len; pub const max_clients = 32; /// Bytes of un-drained CONTROL messages a client may owe before it is closed. -/// Frames are not in here (see the module header), so this is a backlog of -/// ACTIONS — spawns, clipboard mirrors, file writes — and a frontend that has -/// not taken 1 MiB of those has stopped reading its socket. Checked before an -/// append rather than after, so one oversized message is never the thing that -/// trips it. +/// Frames are not in here (see the module header), so this bounds a backlog of +/// the three things that are still on the wire — a welcome, a clipboard mirror, +/// a link to open — and a frontend that has not taken 1 MiB of those has +/// stopped reading its socket. Checked before an append rather than after, so +/// one oversized message is never the thing that trips it. const out_backlog = 1 << 20; /// One read per client per poll round (see `receive`). 16 KiB is two orders of @@ -148,16 +228,73 @@ const out_backlog = 1 << 20; /// 4 MiB paste arrives across several rounds, which is the point. const read_chunk = 16 * 1024; +/// Bytes taken off one pane's pty per poll round. 64 KiB is what every other +/// host's pty reader uses (`readPty` in tty.zig, gui.zig and macos.zig), and it +/// sits on `readPty`'s own frame rather than the loop's. Nothing is copied out +/// of it: `Event.output` borrows the buffer for one `update` call, so a daemon +/// serving a shell that is printing a build log asks the allocator for nothing. +const pty_chunk = 64 * 1024; + +/// Descriptors in the ONE poll this process runs: the listener, every frontend, +/// every pane's pty master, and the inotify descriptor behind every watch. 50 +/// on a full house, and one syscall covers all of them. +const poll_slots = 1 + max_clients + pardes.MAX_PANES + 1; + +/// How many times `reloadWatched` will honour `file_watch.reloadChanged`'s +/// request for another pass within one round. See `reloadWatched`. +const reload_retries = 4; + /// Bytes of client traffic — every in-queue and out-queue together — this /// session may hold before it starts closing the peers holding it. /// `out_backlog` bounds ONE slot and this bounds the table, which is not the -/// same ceiling: 32 clients each a byte under their own cap is 32 MiB of a -/// daemon nobody is looking at. 4 MiB is one whole paste in flight plus every -/// frame queue a real session builds, and past it the fattest peer is the peer -/// that stopped reading. The mirrors are NOT in this number: a mirror is this -/// session's own bookkeeping for a client it chose to serve, not something a -/// peer can grow. -const session_backlog = 4 << 20; +/// same ceiling: a client's `out` tops out at `out_backlog` plus the one +/// oversized message allowed through whole, so `out_backlog` alone permits +/// 32 * (1 + 1.6) MiB, about 83 MiB of a daemon nobody is looking at. +/// +/// DERIVED, and the derivation IS the fix. This was the literal `4 << 20`, +/// which was by coincidence the exact value of tty.zig's `max_paste_bytes` — +/// and `in` grows to hold one WHOLE message, so a frontend assembling the very +/// paste wire.zig names as one of the two messages that set `max_payload` +/// crossed the table's ceiling while still receiving it. The session then +/// closed the only frontend it had, mid-paste, with `.backlog`, which is the +/// diagnostic for a peer that STOPPED reading. The documented maximum paste +/// could not complete. Two whole `max_payload`s is the smallest number that is +/// headroom rather than another coincidence: one peer may legitimately be +/// assembling a message of the largest size `framed` will accept while the rest +/// of the table holds frames, and past 32 MiB the fattest peer is the peer that +/// stopped draining. Neither the mirrors nor the pane queues are in this +/// number: a mirror is this session's own bookkeeping for a client it chose to +/// serve, and a pane is bounded per pane by `pty_backlog` because it is not a +/// peer and cannot be closed to reclaim anything. +const session_backlog = 2 * @as(usize, wire.max_payload); + +/// Bytes of un-drained INPUT one pane's shell may owe before more is refused. +/// +/// A pane is not a client, so the answer cannot be `out_backlog`'s: a client +/// that stops draining is closed, and the thing at the other end of a pty is a +/// program the human is running. This refuses the write and says so on the +/// pane's message row instead, which is the only honest answer left — dropping +/// input silently loses half a command line, and killing a shell to reclaim a +/// megabyte destroys work. +/// +/// Checked BEFORE the append, exactly as `queue` checks `out_backlog`, and that +/// is what makes 1 MiB enough: any single write lands whole, so a maximum paste +/// into an empty queue is never truncated. What gets refused is MORE input typed +/// at a program that has stopped reading its input at all — `sleep 3600`, a +/// stopped job, anything blocked on its own output. +const pty_backlog = 1 << 20; + +/// How long a connection has to say `hello`, and the ONE number both ends of +/// this transport time the handshake against. `pub` because a frontend that +/// waited longer than the session is willing to hold its slot would report a +/// timeout for a slot that had already been taken back, and a frontend that +/// waited less would give up on a session that was still going to answer — two +/// halves of one deadline, and two literals is how they drift apart. +/// +/// The `Session` field it initialises is a field and not this constant for +/// exactly one reason: the test for expiry would otherwise have to sleep five +/// seconds. See `Session.greet_deadline_ms`. +pub const greet_deadline_default_ms: u32 = 5_000; /// What a DRAINED client is allowed to keep. `in` grows to hold one whole /// message, so a single 4 MiB paste otherwise leaves 4 MiB resident in that @@ -206,36 +343,52 @@ const Client = struct { accepted_ms: i64 = 0, }; -/// A pane's shell: which frontend was asked to fork it, and where. +/// A pane's shell: forked by THIS process, drained by its poll set, reaped by +/// it. /// -/// WHY THE SESSION REMEMBERS THIS. A spawn is the one primary-only call that -/// has to survive having no frontend to serve it. Every boot layout creates its -/// panes before the socket exists, so a `--detach` performs its startup spawns -/// with nobody attached — and dropping them meant a session that opened with -/// panes whose shells had never been forked, forever, in silence. So a spawn -/// with no primary is OWED, and asked of whoever attaches next. -/// -/// It is also what makes `pty_write` reach the right process. A pane's pty -/// lives in the frontend that forked it, which is not always the primary: A -/// attaches and forks the shells, B attaches, A leaves — the panes are re-owed -/// to B, and then C attaching into A's freed slot becomes primary while the -/// ptys are in B. Routing a pane's bytes by its OWNER rather than by the -/// primary is the difference between typing into a shell and typing into -/// nothing. -const Shell = struct { - /// The slot that was asked to fork this pane's shell, or null when nobody - /// has been. - owner: ?u8 = null, - /// A spawn owed to whoever attaches next: either it was never asked, or the - /// frontend holding it left and took the pty with it. - owed: bool = false, - /// Copied, because `push_spawn`'s `cwd` borrows the core's memory for the - /// length of that one call and this outlives it by definition. - cwd: std.ArrayListUnmanaged(u8) = .empty, +/// There is no owner here and nothing is owed, and the absence is the whole +/// change. This struct used to record which frontend had been asked to fork a +/// pane and re-ask the next arrival when that frontend left, because the pty +/// lived in the frontend that forked it; and a `--detach`, whose panes always +/// exist before its socket does, had nobody to ask at all and had to remember +/// the request instead. Both were one problem, and forking here dissolves both: +/// a startup layout's shells are forked during `run`'s pre-loop drain with +/// nobody attached, and they are still those same shells when the tenth +/// frontend attaches an hour later. +const Pty = struct { + /// The pty master, non-negative exactly when this pane has a live shell. + /// While it is here it is in the poll set (`waitInput`), NON-BLOCKING like + /// every other descriptor this file holds — `spawn` flips it, because + /// `forkpty` hands it back blocking and tty.zig's streaming reader wants it + /// that way. + fd: c_int = -1, + /// Kept past the fork for `look.shellCwd` and `look.ttyTaken`, both of which + /// ask /proc about this pid rather than about the descriptor. + pid: posix.pid_t = 0, + /// Bytes owed to this shell's stdin, drained by POLLOUT and bounded by + /// `pty_backlog`. The same shape as `Client.out`, for the same reason: the + /// thing on the far side may not be reading, and this process must not wait + /// to find out. See `ptyWrite`. + out: std.ArrayListUnmanaged(u8) = .empty, +}; + +/// What one descriptor in `waitInput`'s poll set is. A tagged union rather than +/// the bare slot index this loop used to carry alongside its `pollfd`s, because +/// the set now holds four different kinds of thing and a `u8` cannot say which. +const Source = union(enum) { + listener, + client: u8, + pty: u8, + inotify, }; pub const Session = struct { gpa: std.mem.Allocator, + /// The Io every filesystem read this host performs goes through: + /// file_watch.zig's reload of a changed pane, and `user_config.dumpThemes`. + /// Required and not optional — a session that owns the disk work cannot be + /// handed a null disk. + io: std.Io, core: *pardes.Pardes, /// -1 when nothing is bound: an unsupported platform, or a bind that /// failed. A session with no listener is a session nobody can attach to, @@ -258,9 +411,33 @@ pub const Session = struct { /// needed rather than sized from `wire.max_payload`, which would be 16 MiB /// of resident memory for a session whose frames are six kilobytes. scratch: std.ArrayListUnmanaged(u8) = .empty, - /// Where each pane's shell lives, and which spawns are still owed. See - /// `Shell`. - shells: [pardes.MAX_PANES]Shell = @splat(.{}), + /// Each pane's shell. Forked here, drained by the poll set, reaped by + /// `harvest`. See `Pty`. + ptys: [pardes.MAX_PANES]Pty = @splat(.{}), + /// The OSC 133 rc files a forked shell sources, staged once for the life of + /// this host exactly as tty.zig stages them for the life of a terminal: + /// `shell_bin.resolve` hands a child pointers into these buffers and the + /// child holds them until it execs, so they must not live in a stack frame. + /// The default is the empty one, which `resolve` reads as "this shell gets + /// no prompt marks"; `run` supplies a staged one. + prompt_rcs: shell_bin.PromptRcs = .{}, + /// The one inotify descriptor behind every watch this session holds, and the + /// last member of the poll set. Opened lazily — see `inotify`. + inotify_fd: c_int = -1, + /// Which directory mark belongs to which pane, and the generation the core + /// has already accepted from each. file_watch.zig owns the shape and the + /// transaction; this host owns only the descriptor and the wake. + watches: file_watch.Table = @splat(null), + /// A reconcile pass is due: a watched directory had an edge, or the last + /// pass asked for another one. Consumed at the end of `waitInput`, which is + /// where a core change still makes the current frame — `Pardes.pump` renders + /// after `pull_wait_input` returns. + check_files: bool = false, + /// False during `run`'s pre-loop effect drain. Read in exactly one place: + /// `Pardes.loadThemeFile` animates an interactive theme change and must not + /// animate a startup one, and a `ThemeFile` in a boot layout is a startup + /// one. tty.zig spells the same distinction `threads_ok`. + in_loop: bool = false, /// How long a connection may stay silent before the session takes its slot /// back. `Client.open` writes its `hello` in the same call that connects, /// so a peer that has said nothing for five seconds is not a frontend that @@ -268,8 +445,10 @@ pub const Session = struct { /// real frontend out with a `refuse .full`. /// /// A field rather than a constant for exactly one reason: the test for that - /// would otherwise have to sleep five seconds. Nothing else changes it. - greet_deadline_ms: u32 = 5_000, + /// would otherwise have to sleep five seconds. Nothing else changes it, and + /// the number itself is `greet_deadline_default_ms`, which the frontend half + /// of this transport reads too. + greet_deadline_ms: u32 = greet_deadline_default_ms, /// Monotonic milliseconds until which the LISTENER is left out of the poll /// set, because an `accept` failed for a reason that persists. See `accept`. accept_paused_ms: i64 = 0, @@ -286,7 +465,19 @@ pub const Session = struct { for (&s.clients) |*c| if (c.fd >= 0) s.close(c, .quitting); s.unlisten(); s.scratch.deinit(s.gpa); - for (&s.shells) |*sh| sh.cwd.deinit(s.gpa); + // The pane shells go with the SESSION and not with a frontend, which is + // this file's whole change. Closing a master is what hangs its shell up; + // `harvest` collects whatever has already exited, and the process is + // about to leave, so anything slower than that is the kernel's job. + for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); + s.harvest(); + // Every mark dies with the descriptor, so there is nothing to unmark. + if (s.inotify_fd >= 0) { + _ = libc.close(s.inotify_fd); + s.inotify_fd = -1; + } + // Unlinks the two rc files staged for this host's shells. + s.prompt_rcs.deinit(); } /// Bind and listen. False when there is no socket, and a session without @@ -362,17 +553,31 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } - /// Thirteen methods, and the seven that are missing are missing on purpose - /// — see wire.zig's header for each one's reason. `push_poll_frame` and - /// `push_post_present` carry no information a frame does not; the four - /// synchronous or dispatched pulls and `push_fs_reply` belong to whoever - /// owns the core, which is this process. + /// Sixteen methods, and NOT the fullest host in the tree — that claim stood + /// here, was believed, and was copied into docs/detached.md before an audit + /// counted the others. The tty and SDL shells fill NINETEEN each (everything + /// but `pull_gpio_toggle` and `push_detach`) and macOS fourteen, so this host + /// is the only one that implements `push_detach` and otherwise the least + /// complete of the three desktop hosts. What is true is narrower and is the + /// point anyway: it performs every MACHINE-LOCAL effect there is, and the + /// five of host.zig's twenty-one it leaves null are null because there is + /// nothing here for them to do. Three of those five are real losses a person + /// can notice — no `pull_lsp` and no `pull_pipe`, because both want the + /// worker pool this deliberately single-threaded loop does not have, and no + /// `push_fs_reply`, because this process mounted no /dev/fuse. The other two + /// are not losses at all: `push_post_present` marks the moment a frame + /// reached a screen and this process has no screen, and `pull_gpio_toggle` + /// wants pads. + /// + /// `push_detach` is the one entry here that is not an effect. See `detach`. const vtable: host_api.Host.VTable = .{ .pull_wait_input = waitInput, .push_present = present, + .push_poll_frame = pollFrame, .push_spawn = spawn, .push_pty_write = ptyWrite, .push_pty_resize = ptyResize, + .pull_tty_taken = ttyTaken, .push_write_file = writeFile, .push_write_dump = writeDump, .push_watch_file = watchFile, @@ -381,6 +586,7 @@ pub const Session = struct { .push_set_clipboard = setClipboard, .pull_read_clipboard = readClipboard, .push_open_link = openLink, + .push_detach = detach, }; // ---- routing ---------------------------------------------------------- @@ -402,16 +608,6 @@ pub const Session = struct { return s.primary(); } - /// The frontend holding pane `pane`'s pty, which is NOT the primary in - /// general — see `Shell`. Null when no frontend holds it, which is a pane - /// with no child: the core's own answer to that is silence, and so is this. - fn holder(s: *Session, pane: u8) ?*Client { - if (pane >= s.shells.len) return null; - const owner = s.shells[pane].owner orelse return null; - const c = &s.clients[owner]; - return if (c.attached) c else null; - } - /// Which slot this client is. From the pointer because every caller here /// holds a `*Client` and not its index. fn slotOf(s: *Session, c: *const Client) u8 { @@ -422,84 +618,202 @@ pub const Session = struct { for (&s.clients) |*c| if (c.attached) s.send(c, msg); } - // ---- the host methods ------------------------------------------------- + // ---- the host methods: pseudo-terminals ------------------------------- fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); - if (pane >= s.shells.len) return; // the core indexes its own panes - const sh = &s.shells[pane]; - // Kept whether or not there is somebody to ask, because the pane now - // exists either way and the cwd is the only thing that cannot be - // reconstructed later. - sh.cwd.clearRetainingCapacity(); - sh.cwd.appendSlice(s.gpa, cwd) catch {}; - if (s.primary()) |c| { - sh.owner = s.slotOf(c); - sh.owed = false; - return s.send(c, .{ .spawn = .{ .pane = pane, .cwd = cwd } }); + if (pane >= s.ptys.len) return; // the core indexes its own panes + // The in-process host's reaping rule and its reason, verbatim from + // tty.zig `spawn`: the core reuses pane ids and there is no close + // effect, so a deleted pane's shell lives in its slot until a respawn + // lands here. + s.closePty(pane); + var cwd_buf: [256:0]u8 = undefined; + var cwd_z: ?[*:0]const u8 = null; + if (cwd.len > 0 and cwd.len < cwd_buf.len) { + @memcpy(cwd_buf[0..cwd.len], cwd); + cwd_buf[cwd.len] = 0; + cwd_z = @ptrCast(&cwd_buf); } - // Nobody can fork a shell right now — a startup layout, or every - // frontend gone. NOT dropped: `reconcile` asks the next arrival. - sh.owner = null; - sh.owed = true; + const child = host_io.forkShell( + s.core, + pane, + &s.prompt_rcs, + s.core.shellBin(), + cwd_z, + // The SESSION grid — which `reconcile` already made the smallest + // common one across everyone attached, and which survives every + // frontend leaving, so a shell forked into an empty session is + // still sized like the pane the core reflowed. + s.core.screen_h, + s.core.screen_w, + null, // no `--fs` mount in a daemon: nothing here answers /dev/fuse + ); + // A `forkpty` that failed left `master` holding a number this process + // does not own. The shells get away with not checking because they hand + // the descriptor to a reader task that simply ends; this one would go + // into `poll(2)`, come back POLLNVAL, and be closed out from under + // whoever really owns it. + if (child.pid < 0) return; + s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid }; + // ...and the master joins the rule every other descriptor in this file + // obeys. `forkpty` hands it back BLOCKING, and host_io.zig leaves it that + // way because tty.zig streams it from a thread that wants a blocking + // read; a poll loop wants the opposite, and one blocking `write(2)` here + // is the whole session parked. Only this side of the pty is affected — + // the master and the slave are separate open file descriptions, so the + // shell's own stdin stays exactly as `forkpty` made it. + setNonblock(child.file.handle); + // The pane's starting directory, for the tags. `pollFrame` keeps it + // current after a `cd`; this is the one before the first frame. + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); } + /// Keystrokes and pastes into the shell, QUEUED and never blocked on. + /// + /// This was one blocking `host_io.writeFd`, and the comment defending it + /// argued that "the peer is a shell this process forked rather than a + /// stranger who can stop reading on purpose". The peer is whatever program + /// the human ran in the pane: `sleep 3600`, a job stopped with ^Z, anything + /// blocked writing its own output. Any of those plus a paste larger than the + /// pty's input buffer — four kilobytes, and a frontend is entitled to send a + /// four-MEGABYTE paste — put this single-threaded process to sleep inside + /// `write(2)` with the whole session behind it: no frame to any frontend, + /// fifteen other masters unread, no `accept`, no `expire`, no inotify drain. + /// + /// So a pane owes bytes the way a client does, and the answer is the shape + /// this file already had for exactly this problem. What differs is what + /// happens when the queue will not drain: a client that stops reading is + /// CLOSED, and a pane cannot be, because closing it kills a program the + /// human is running. See `pty_backlog` — the write is refused and said out + /// loud on the pane's own message row. fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = of(ctx); - // To the frontend that forked this pane's shell, not to the primary: - // the pty is in that process and nowhere else. A pane whose holder is - // gone is silent, which is what the core does with a null method. - if (s.holder(pane)) |c| s.send(c, .{ .pty_write = .{ .pane = pane, .bytes = bytes } }); + if (pane >= s.ptys.len) return; + const pt = &s.ptys[pane]; + // A pane with no shell swallows what is typed at it, which is exactly + // what the core does with a null method. + if (pt.fd < 0) return; + // BEFORE the append, which is `queue`'s rule and gives `queue`'s + // guarantee: one write always lands whole, so the biggest paste anyone + // can send is never truncated on arrival, and what is refused is the + // NEXT one typed at a program that has read nothing. + if (pt.out.items.len > pty_backlog) { + var mbuf: [256]u8 = undefined; + const text = std.fmt.bufPrint( + &mbuf, + "input refused: pane not reading ({d} bytes queued)", + .{pt.out.items.len}, + ) catch "input refused: pane not reading"; + return s.core.setMessage(pane, text); + } + pt.out.appendSlice(s.gpa, bytes) catch { + // Out of memory for a keystroke. The shell is fine and the session + // is fine; this one write is not, and saying so is all there is. + return s.core.setMessage(pane, "input refused: out of memory"); + }; + // Try immediately. On an idle pty this empties the queue in one write and + // the descriptor never asks for a POLLOUT at all, which keeps a session + // of keystrokes exactly as cheap as it was. + s.flushPty(pane); } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = of(ctx); - if (s.holder(pane)) |c| s.send(c, .{ .pty_resize = .{ .pane = pane, .cols = cols, .rows = rows } }); + if (pane >= s.ptys.len) return; + const fd = s.ptys[pane].fd; + if (fd < 0) return; + const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(fd, TIOCSWINSZ, @intFromPtr(&ws)); + } + + /// Is this pane's tty still the prompt we forked, or has a program taken it? + /// + /// Answerable at all only because the pty is HERE. While a pane's shell + /// lived in a frontend this method had to stay null, and a null one means + /// the core types every `Exec` at the shell — into vim, into a pager, into + /// an agent waiting on stdin. Lazy by construction (host.zig): it runs where + /// the core is about to type a command line, so the /proc walk costs an + /// ordinary frame nothing. + fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { + const s = of(ctx); + if (pane >= s.ptys.len) return false; + const pt = s.ptys[pane]; + if (pt.fd < 0) return false; + return look.ttyTaken(pt.pid, pt.fd); } + // ---- the host methods: the filesystem --------------------------------- + fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - if (s.primary()) |c| return s.send(c, .{ .write_file = .{ .pane = pane, .path = path, .bytes = bytes } }); - // Nobody attached, and a Put must not evaporate. This method being - // non-null means the core did NOT reach for its own filesystem, so the - // obligation a null method would have discharged is discharged here by - // hand — the same shape `readClipboard` below has, and host.zig's rule - // that a zero-method host is a complete pardes. - s.core.fallback.writeFile(path, bytes); + if (!host_io.writeFileBytes(path, bytes)) return; + // Our own write is about to come back as an inotify edge: restamp from + // the bytes we just put there so the reconcile reads as "no change". + // Only when this IS the pane's watched file — a `Save <elsewhere>` must + // not silence a real change to the file the pane has open. Six lines + // shared with tty.zig `writeFile` over the same `file_watch.Table`, + // which is what makes a save in a detached pane behave like a save in a + // terminal one. + if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { + if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { + .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, + .pdf => {}, + }; + }; + // ...and say so on the pane's message row. AFTER the write, not beside + // it: the early return above is a save that did not happen and must not + // be reported as one. + var mbuf: [256]u8 = undefined; + s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = of(ctx); - if (s.primary()) |c| return s.send(c, .{ .write_dump = bytes }); - // ...and the same for a Dump, including the part that makes the bytes - // reachable again: a real host reports where it landed, which is what - // puts `Restore <path>` in the topbar (pardes.zig `write_dump`). - s.core.fallback.writeFile(pardes.fallback_dump_path, bytes); - s.core.setLastDump(pardes.fallback_dump_path); + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf) orelse return; + if (!host_io.writeFileBytes(path, bytes)) return; + // Where it landed, which is what puts `Restore <path>` in the topbar + // (pardes.zig `write_dump`). A dump of a detached session now lands in + // the same directory a terminal session's does, rather than in whatever + // directory the frontend that happened to be primary was started from. + s.core.setLastDump(path); } - fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { const s = of(ctx); - if (s.primary()) |c| return s.send(c, .{ .watch_file = .{ .pane = pane, .path = path, .on = on } }); - // No frontend to watch a path, so the core's own record of what was - // asked is the whole of what a watch means here — exactly what a null - // method leaves behind. - if (pane < s.core.fallback.watched.len) s.core.fallback.watched[pane] = on; + // The path argument is unused because `applyEffect` takes it off the + // core's own pane, together with the serial and the generation that make + // the reconcile safe. That is the one thing a frontend could not do — it + // had no core — and it is why the frontend's copy of this method needed a + // second table of pathnames to go with the watch table. + if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify(), &s.watches, pane, on)) + s.check_files = true; } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { const s = of(ctx); - // Dropped with nobody attached, and that is the whole of it: the core's - // own `theme_file` effect does nothing for a null method either, so - // there is no obligation left over. Same for `dump_themes` below. - if (s.primary()) |c| s.send(c, .{ .watch_theme = .{ .generation = generation, .on = on } }); + if (file_watch.applyThemeEffect(s.core, s.gpa, s.inotify(), &s.watches, generation, on, s.in_loop)) + s.check_files = true; } fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); - if (s.primary()) |c| s.send(c, .{ .dump_themes = .{ .pane = pane } }); + // A session started without one has nowhere to put them; the core's + // options are the only place that answer lives. + const config_dir = s.core.opts.config_dir orelse return; + const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + s.core.reportError(pane, "dump themes", err); + return; + }; + defer s.gpa.free(out_dir); + var mbuf: [256]u8 = undefined; + s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } + // ---- the host methods: the desktop ------------------------------------ + fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); // Mirrored into the core's own clipboard ALWAYS, not only when nobody @@ -533,6 +847,254 @@ pub const Session = struct { s.core.fallback.setLink(url); } + // ---- the host methods: session control -------------------------------- + + /// `Detach` in an attached frontend: that frontend leaves, the session and + /// every other frontend carry on. tmux's `detach-client`. + /// + /// A `send` and NOTHING ELSE, and each of the three things it does not do is + /// deliberate. It does not quit — the whole point is that the session + /// survives, and a detach that took the daemon with it would be `quit` under + /// another name. It does not touch the core — no pane closes, no shell dies, + /// no frame changes; the grid is retaken by `reconcile` from the frontends + /// that remain, on the ordinary path, because a frontend leaving is already a + /// case this file handles. And it does not close the connection: the frontend + /// closes its own socket when it reads the message, and the peer-hangup path + /// then frees the slot exactly as it does for a frontend somebody killed. + /// Closing from this side would race the frontend's own teardown for no gain. + /// + /// It is therefore the one vtable entry here that is not an effect on the + /// world but SESSION CONTROL — one frontend asking to stop being a frontend + /// — which is why wire.zig numbers it 0x05, in the session range beside + /// `quit`, rather than in 0x10.. where every tag reaches a disk, a clipboard + /// or a browser. The module header's routing table says the same. + /// + /// ORIGIN, ELSE PRIMARY, for `read_clipboard`'s and `open_link`'s reason: it + /// answers something one particular human just typed, so it has to reach that + /// human's screen and not somebody else's — sending a detach to the wrong + /// frontend takes away a session from a person who did not ask. Nobody + /// attached at all is a no-op, and correctly so: there is no frontend to + /// detach, and the core has nothing to record about one. + fn detach(ctx: ?*anyopaque) void { + const s = of(ctx); + if (s.origins()) |c| s.send(c, .detach); + } + + // ---- pane shells ------------------------------------------------------ + + /// Each pane's live cwd, for the tags. One readlink of /proc per pane that + /// has a shell, per frame, which is what tty.zig's `pollFrame` costs — and + /// why the far more expensive question, whether a program has taken the + /// pane's tty, is a pull asked at the `Exec` that cares (`ttyTaken`) + /// instead of polled here. + /// + /// This could not exist before. A detached session's shells lived in a + /// frontend, and a frontend has no core to report a cwd TO, so a `cd` in a + /// detached pane never reached its tag no matter how many frontends were + /// watching. The pids are here now, so it does. + fn pollFrame(ctx: ?*anyopaque) void { + const s = of(ctx); + for (&s.ptys, 0..) |*pt, pane| { + if (pt.fd < 0) continue; + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(pane, cwd); + } + } + + /// Drop a pane's shell: out of the poll set, out of the process. Closing the + /// master is what hangs the shell up — which is true only because + /// `host_io.forkShell` puts FD_CLOEXEC on it, so no LATER pane's shell is + /// still holding a copy open. The pid is left to `harvest`, because a + /// `waitpid` here would return 0 for a shell that has not noticed the hangup + /// yet and that answer is worth nothing. + /// + /// The core is NOT told. Its two callers are `spawn` — a respawn, where the + /// core is the thing that asked — and `deinit`, where there is no core left + /// to tell. The path that does tell it is `paneEof`. + fn closePty(s: *Session, pane: u8) void { + const pt = &s.ptys[pane]; + if (pt.fd < 0) return; + _ = libc.close(pt.fd); + // Before the reset, or the queue's allocation goes with the slot: what + // is in it is input a program that is not reading never took, and there + // is nobody left to hand it to. + pt.out.deinit(s.gpa); + pt.* = .{}; + } + + /// Push what the kernel will take of what this pane owes its shell, and + /// leave the rest for a POLLOUT. `flush`'s body, on a pty instead of a + /// socket, down to the `retire` that hands a drained megabyte back. + /// + /// The one difference is what an error means. A failed write to a SOCKET + /// closes a client; a failed write to a master means the slave side is gone, + /// which is the same event as a read of 0. It is NOT the same moment, + /// though, and that is why the error arm reads the pane before it ends it: + /// linux's `n_tty_write` returns EIO the instant the slave has no open + /// descriptors left, while `n_tty_read` on that same master still hands back + /// what the shell wrote before it went — so the write fails while the last + /// line is still retrievable, and ending the pane first would throw it away. + /// That is the very thing the dispatch's `.pty` branch protects against when + /// it takes POLLIN before POLLHUP, and it has to hold here too, because two + /// paths reach this arm with no read of their own in between: the dispatch + /// runs POLLOUT before POLLIN, and `ptyWrite` calls this during `perform`, + /// after this round's `readPty` has already been and gone. + fn flushPty(s: *Session, pane: u8) void { + const pt = &s.ptys[pane]; + var off: usize = 0; + while (off < pt.out.items.len) { + const n = libc.write(pt.fd, pt.out.items.ptr + off, pt.out.items.len - off); + if (n < 0) switch (libc.errno(n)) { + .INTR => continue, + // The pty's input buffer is full: the rest waits for POLLOUT, + // and this is the case the whole change exists for. + .AGAIN => break, + else => { + // `readPty` either takes that last chunk or reaches the end + // itself and has already ended the pane; the guard is what + // stops the second `paneEof` from being a double-end. + s.readPty(pane); + if (s.ptys[pane].fd >= 0) s.paneEof(pane); + return; + }, + }; + // No progress and no error. host_io.zig's `writeFd` says why this is + // a `break` and never a retry: looping on a zero-byte write is a + // spin, and a spin in here is the whole session at 100% of a core + // with no syscall for a signal to interrupt. + if (n == 0) break; + off += @intCast(n); + } + if (off == 0) return; + if (off == pt.out.items.len) { + pt.out.clearRetainingCapacity(); + return retire(s.gpa, &pt.out); + } + std.mem.copyForwards(u8, pt.out.items, pt.out.items[off..]); + pt.out.items.len -= off; + } + + /// One read per readable pty per round — `receive`'s rule for clients, + /// applied to shells: a `yes` in pane 1 gets one turn and the loop moves on + /// to the other panes, the frontends and the frame. + /// + /// Nothing is copied. `Event.output` borrows the buffer for the length of + /// one `update` call, which is the same borrow window every other host gives + /// a pty chunk — tty.zig frees its duplicate the line after the update — + /// except that this one never allocated a duplicate to free. A daemon + /// serving sixteen shells printing build logs asks the allocator for + /// nothing. + fn readPty(s: *Session, pane: u8) void { + var buf: [pty_chunk]u8 = undefined; + const got = libc.read(s.ptys[pane].fd, &buf, buf.len); + if (got == 0) return s.paneEof(pane); + if (got < 0) return switch (libc.errno(got)) { + // A master that said POLLIN and then had nothing is not an error; + // the next round asks again. + .INTR, .AGAIN => {}, + // EIO is how linux reports the slave side going away, which is the + // ordinary end of a shell rather than a fault. + else => s.paneEof(pane), + }; + s.core.update(.{ .output = .{ .pane = pane, .bytes = buf[0..@intCast(got)] } }); + } + + /// The shell in `pane` is gone. The descriptor leaves the poll set BEFORE + /// the core is told, because an `eof` is what makes the core offer a respawn + /// and a respawn into a slot still holding the old fd would leak it. + fn paneEof(s: *Session, pane: u8) void { + s.closePty(pane); + s.harvest(); + s.core.update(.{ .eof = .{ .pane = pane } }); + } + + /// Collect every child that has exited. + /// + /// `waitpid(-1)` and not a pid list, because the only children this process + /// forks are pane shells (`host_io.forkShell`) — so "any exited child" and + /// "an exited pane shell" are the same set — and because the pids a list + /// would hold are exactly the ones it cannot help with: a respawn closes a + /// master, and the shell that gets the hangup exits some milliseconds later + /// with its slot already reused by a different shell. + /// + /// Nothing here waits, so a session whose shells are all running pays one + /// syscall that returns 0. Called once per poll round and again wherever a + /// shell is dropped, which is what keeps a daemon that runs for a week and + /// spawns a thousand shells free of zombies — the one bookkeeping cost a + /// long-lived process pays that a frontend, which exits, never did. + fn harvest(_: *Session) void { + while (true) { + // 0: there are children and none has exited. -1: no children at all. + if (libc.waitpid(-1, null, libc.W.NOHANG) <= 0) return; + } + } + + // ---- watched files ---------------------------------------------------- + + /// The one inotify descriptor behind every watch, opened on first use. + /// + /// Lazy for two reasons pointing the same way: a `Session` is built as a + /// struct literal (client.zig's test harness is one) and so has no init hook + /// to open it in, and a session whose panes are all shells never watches a + /// path and has no use for one. -1 on anything but linux and on a failed + /// `inotify_init1`, which file_watch.zig reads as "mark nothing" — the core + /// then keeps its own record of what was asked and simply never gets a + /// reload, which is what a host with no watcher has always done. + /// + /// NONBLOCK because this descriptor is drained from `poll`, not from a + /// thread parked in `read` (tty.zig `watchFiles`): `drainInotify` must be + /// able to stop. + fn inotify(s: *Session) c_int { + if (s.inotify_fd >= 0) return s.inotify_fd; + // The whole body is inside the comptime branch so that neither + // `inotify_init1` nor `linux.IN` is even analysed on a platform that has + // no inotify — the same shape file_watch.zig's `watchPath` uses. + if (comptime builtin.os.tag == .linux) { + s.inotify_fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK); + } + return s.inotify_fd; + } + + /// A directory this session marked had an edge. The CONTENTS are discarded + /// on purpose, exactly as tty.zig's watcher thread discards them: a record + /// names a mark and a filename, and reconciling every mark against the + /// generation the core accepted is both cheaper and safer than deciding from + /// the record which pane it meant. + /// + /// DRAINED TO EMPTY, in a loop, and one read was a real cost rather than the + /// coalescing this comment used to claim. The descriptor is level-triggered, + /// so a queue left partly full makes `poll` return ready again immediately — + /// and each of those rounds is a whole `pump`: `reloadChanged` over all 17 + /// slots, every watched text pane re-read from disk and re-hashed, a render, + /// a present. A `git checkout` can queue the kernel's whole 16384 events; at + /// roughly 128 records per 4 KiB that was ~128 spin rounds and some two + /// thousand whole-file reads for one command, at 100% of a core, while every + /// frontend got a frame per round it could not use. The fd is IN_NONBLOCK + /// (`inotify`), so the loop ends on EAGAIN. + fn drainInotify(s: *Session) void { + var buf: [4096]u8 = undefined; + while (libc.read(s.inotify_fd, &buf, buf.len) > 0) s.check_files = true; + } + + /// Reconcile every marked pane and the theme file. Called at the END of + /// `waitInput`, which is what puts a reload in THIS frame: `Pardes.pump` + /// renders after `pull_wait_input` returns. + /// + /// The loop is `reloadChanged`'s contract. It asks for another pass when a + /// PDF's pathname changed between the stat before MuPDF reopened it and the + /// stat after — a save that landed mid-reconcile, where committing either + /// identity would lose a generation. tty.zig posts that request back into + /// its event queue; this loop has no queue, so it is retried here and + /// BOUNDED, because a file being rewritten in a loop must not hold the core. + /// What is left over is picked up by the next directory edge. + fn reloadWatched(s: *Session) void { + if (!s.check_files) return; + s.check_files = false; + for (0..reload_retries) |_| { + if (!file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) return; + } + } + // ---- the frame -------------------------------------------------------- fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { @@ -590,19 +1152,37 @@ pub const Session = struct { // ---- the loop --------------------------------------------------------- /// The only place this process sleeps, which is what `pull_wait_input`'s - /// comment in host.zig requires of whoever serves it. One `poll(2)` covers - /// the listener and every attached frontend; there is no thread per client - /// and nothing here blocks on a single peer. + /// comment in host.zig requires of whoever serves it, and the only place it + /// waits on ANYTHING: one `poll(2)` over the listener, every attached + /// frontend, every pane's pty master and the inotify descriptor. No thread + /// per client, no thread per shell, no watcher thread, and nothing here + /// blocks on a single peer. + /// + /// That the shells are in this set and not on threads of their own is what + /// lets a daemon own sixteen of them and stay a single-threaded state + /// machine — and it costs the shells nothing, because a pty master is + /// pollable and a pane's output has nowhere to go but the core this loop is + /// driving anyway. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); // Push what the kernel will take before sleeping: a client that becomes // writable while we are inside poll(2) would otherwise be a frame late, // and a frame late is a frame skipped (see `present`). for (&s.clients) |*c| if (c.fd >= 0) s.flush(c); + // The session grid, retaken BEFORE the sleep as well as after it. A + // client can leave OUTSIDE this function — a `set_clipboard` broadcast + // whose write failed during `perform` closes it — and the minimum across + // attached frontends would then stay sized for a frontend that is gone + // until some descriptor happened to become readable, which on an idle + // session is never. That is what this call buys, and `regridded` is what + // it costs: a round that has just told the core to reflow must not then + // sleep on it, because the frame carrying that reflow is the one + // `Pardes.pump` composes the moment this returns. + const regridded = s.reconcile(); const now = monotonicMs(); - var fds: [max_clients + 1]libc.pollfd = undefined; - var slots: [max_clients + 1]u8 = undefined; + var fds: [poll_slots]libc.pollfd = undefined; + var src: [poll_slots]Source = undefined; var n: usize = 0; // The listener is left OUT of the set while accepting is paused, which // is how an EMFILE is waited out without the core sleeping (see @@ -610,7 +1190,7 @@ pub const Session = struct { const watching_listener = s.listener >= 0 and now >= s.accept_paused_ms; if (watching_listener) { fds[n] = .{ .fd = s.listener, .events = poll_in, .revents = 0 }; - slots[n] = 0; + src[n] = .listener; n += 1; } for (&s.clients, 0..) |*c, i| { @@ -620,13 +1200,43 @@ pub const Session = struct { .events = if (c.out.items.len != 0) poll_in | poll_out else poll_in, .revents = 0, }; - slots[n] = @intCast(i); + src[n] = .{ .client = @intCast(i) }; + n += 1; + } + // The pane shells, and note what is NOT conditional on a frontend: a + // session with nobody attached still polls these, still reads them and + // still feeds the core. That is the difference between a detach that + // pauses your build and a detach that does not. + for (&s.ptys, 0..) |*pt, pane| { + if (pt.fd < 0) continue; + fds[n] = .{ + .fd = pt.fd, + // POLLOUT only while this pane owes its shell bytes, which is + // the same rule and the same reason as a client's: asking for it + // unconditionally makes every idle pty a ready descriptor and + // turns the poll into a spin. + .events = if (pt.out.items.len != 0) poll_in | poll_out else poll_in, + .revents = 0, + }; + src[n] = .{ .pty = @intCast(pane) }; + n += 1; + } + // Opened only once something asked to be watched, so an unwatched + // session simply has one fewer descriptor here (see `inotify`). + if (s.inotify_fd >= 0) { + fds[n] = .{ .fd = s.inotify_fd, .events = poll_in, .revents = 0 }; + src[n] = .inotify; n += 1; } - // A detached session with no listener and no clients has no event - // source at all. Returning immediately would spin the outer + // A session with no listener, no clients, no shells and no watches has + // no event source at all. Returning immediately would spin the outer // `while (!core.quit)` at full speed, so sleep the interval the core // offered and, when it offered none, a frame's worth. + // + // Nothing is owed on this path. `check_files` is only ever set by a + // watch, and a watch means the inotify descriptor is in the set; and + // `reconcile` posts a resize only when a client is ATTACHED, which means + // its socket is in the set — so `n == 0` implies `!regridded` too. if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms); // Zero is the core's word for "sleep until something happens" (see // pardes.zig `pump`: it passes a frame interval only while an animation @@ -639,37 +1249,87 @@ pub const Session = struct { // idle, IS the denial `greet_deadline_ms` exists to answer — so the // wait is clamped to whichever is due first. if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due); + // ...and two things are due on nothing at all rather than on a + // descriptor: a reconcile pass a watch effect asked for (`watchFile` ran + // during `perform`, outside this function) and a regrid this round has + // already performed. Both are consumed before this function returns, so + // the round must not sleep before reaching them. + if (s.check_files or regridded) timeout = 0; const ready = libc.poll(&fds, @intCast(n), timeout); - // Expired unconditionally: a slot held by silence comes back on a - // timeout exactly as it does on a wakeup, and a poll that returned - // nothing is the ordinary way this deadline is reached. + // A timeout is an ordinary frame boundary and EINTR is a signal we do not + // handle here. Neither skips anything below any more: what used to be an + // early `return` here is why a client closed without any descriptor being + // readable — which is every `expire` — left the session grid sized for a + // frontend that had gone, until the next readable event, on an idle + // session possibly hours later. + if (ready > 0) s.dispatch(fds[0..n], src[0..n]); + // AFTER the dispatch, and that ordering is itself a fix. `expire` frees a + // client slot and `accept` — which runs INSIDE the dispatch — fills the + // lowest free one, so an expire that ran first could hand a slot to a new + // connection within this same round and the dispatch would then apply the + // OLD connection's `revents` to the new descriptor: a POLLHUP from the + // peer that left, closing the peer that just arrived. The dispatch's + // `c.fd < 0` guard cannot see that, because the fd is perfectly valid — + // it is simply a different fd. Expiring after means a freed slot is + // refilled no earlier than the next round, which builds a fresh `fds` for + // it. It fixes a smaller thing for free, too: a connection whose `hello` + // arrived in THIS round is attached before its deadline is judged, + // instead of being taken back with its handshake still unread. s.expire(monotonicMs()); - // A timeout is an ordinary frame boundary and EINTR is a signal we do - // not handle here; both simply come back next pump. - if (ready <= 0) return; + // Unconditional, and not only where a shell is noticed to have died: a + // shell whose master `spawn` closed on a respawn exits after that close, + // with no descriptor left for anyone to see it on. See `harvest`. + s.harvest(); + // Both before this function returns, so a file that changed on disk and a + // frontend that left during this round are in the frame `Pardes.pump` + // composes next rather than the one after it. + s.reloadWatched(); + _ = s.reconcile(); + } - var k: usize = 0; - if (watching_listener) { - if (fds[0].revents != 0) s.accept(); - k = 1; - } - while (k < n) : (k += 1) { - const c = &s.clients[slots[k]]; - // A slot closed earlier in this same pass (its peer hung up, a - // decode failed, its handshake expired) must not be touched - // through a stale revents. - if (c.fd < 0) continue; - if (fds[k].revents & poll_out != 0) s.flush(c); - if (c.fd < 0) continue; - if (fds[k].revents & poll_in != 0) { - s.receive(c, slots[k]); - } else if (fds[k].revents & (poll_hup | poll_err | poll_nval) != 0) { - // POLLIN wins when both are set: a peer that wrote and then - // closed has bytes still worth reading. - s.close(c, .peer); - } - } - s.reconcile(); + /// One pass over the descriptors `poll` reported ready. Split out of + /// `waitInput` for one reason: everything that must happen AFTER it — + /// `expire`, `harvest`, `reloadWatched`, `reconcile` — is then stated once, + /// in one order, where no early return can skip it. An early return past + /// that list is exactly what findings 5 and 6 were. + fn dispatch(s: *Session, fds: []const libc.pollfd, src: []const Source) void { + for (fds, src) |pfd, source| switch (source) { + .listener => if (pfd.revents != 0) s.accept(), + .client => |i| { + const c = &s.clients[i]; + // A slot closed earlier in this same pass (its peer hung up, a + // decode failed) must not be touched through a stale revents. + if (c.fd < 0) continue; + if (pfd.revents & poll_out != 0) s.flush(c); + if (c.fd < 0) continue; + if (pfd.revents & poll_in != 0) { + s.receive(c, i); + } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { + // POLLIN wins when both are set: a peer that wrote and then + // closed has bytes still worth reading. + s.close(c, .peer); + } + }, + .pty => |pane| { + if (s.ptys[pane].fd < 0) continue; + // What this pane still owes its shell, which is the whole of + // finding 1's drain: `ptyWrite` queued it and stopped at EAGAIN + // rather than sleeping, and this is where the rest goes. + if (pfd.revents & poll_out != 0) s.flushPty(pane); + // `flushPty` ends the pane when the slave side has gone. + if (s.ptys[pane].fd < 0) continue; + // The same precedence as a client's, and it matters more here: a + // shell that printed its last line and exited reports + // POLLIN|POLLHUP together, and taking the hangup first would + // throw that line away. `readPty` reaches the EOF by reading 0. + if (pfd.revents & poll_in != 0) { + s.readPty(pane); + } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { + s.paneEof(pane); + } + }, + .inotify => if (pfd.revents & poll_in != 0) s.drainInotify(), + }; } /// Milliseconds until the next deadline that is kept by the CLOCK rather @@ -802,16 +1462,30 @@ pub const Session = struct { } fn apply(s: *Session, c: *Client, slot: u8, tag: u8, payload: []const u8) wire.Error!void { - var scratch: wire.Scratch = .{}; - switch (try wire.decodeClient(tag, payload, &scratch)) { + // `Hello.version` BEFORE the payload is decoded, which is the whole + // point of wire.zig putting it first at a fixed offset: a mismatch has to + // stay diagnosable when the rest of the layout is the part that changed. + // Checking it inside the `.hello` arm defeated exactly that guarantee — + // `decodeClient` refuses a cols/rows this build does not like and refuses + // trailing bytes, so a v2 hello with one extra field came back as + // `.protocol` and the `refuse .version` the frontend needs to say + // something useful was never sent. `wire.helloVersion` reads the one + // field without decoding the rest, and lives in the file that owns the + // layout. + if (tag == @intFromEnum(wire.ClientTag.hello)) { + // A second hello on one connection is not a resize; it is a peer + // that is not speaking this protocol. Judged here rather than in the + // arm below so that a repeat hello is a protocol error whatever + // version it claims. + if (c.attached) return error.BadValue; + const claimed = try wire.helloVersion(payload); + if (claimed != wire.version) { + log.debug("frontend speaks protocol {d}, this session speaks {d}", .{ claimed, wire.version }); + return s.refuse(c, .version); + } + } + switch (try wire.decodeClient(tag, payload)) { .hello => |h| { - // A second hello on one connection is not a resize; it is a - // peer that is not speaking this protocol. - if (c.attached) return error.BadValue; - if (h.version != wire.version) { - log.debug("frontend speaks protocol {d}, this session speaks {d}", .{ h.version, wire.version }); - return s.refuse(c, .version); - } if (s.core.quit) return s.refuse(c, .quitting); c.cols = h.cols; c.rows = h.rows; @@ -847,7 +1521,12 @@ pub const Session = struct { /// Settle the session grid and greet whoever arrived, once per poll round /// rather than once per message: three frontends attaching in the same /// round are one resize, not three reflows of every pane. - fn reconcile(s: *Session) void { + /// + /// True when the CORE was told to reflow, which is the one thing a caller + /// has to react to: the frame carrying that reflow is the next one + /// `Pardes.pump` composes, so a `waitInput` that hears true must not go to + /// sleep before returning. See its `regridded`. + fn reconcile(s: *Session) bool { var cols: u16 = 0; var rows: u16 = 0; for (&s.clients) |*c| { @@ -858,6 +1537,7 @@ pub const Session = struct { // Nobody attached: keep the grid we had. A detached session is not a // session of no size, it is one nobody is looking at, and reflowing // every pane to nothing for zero readers is work with no reader. + var regridded = false; if (cols != 0 and (cols != s.cols or rows != s.rows)) { s.cols = cols; s.rows = rows; @@ -867,33 +1547,14 @@ pub const Session = struct { // safe too. for (&s.clients) |*c| c.need_full = true; s.core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + regridded = true; } for (&s.clients, 0..) |*c, i| { if (!c.greet) continue; c.greet = false; s.send(c, .{ .welcome = .{ .slot = @intCast(i), .cols = s.cols, .rows = s.rows } }); } - s.flushOwed(); - } - - /// Hand every owed spawn to the frontend that can serve it. Runs at the end - /// of a poll round, so a frontend that has just been greeted is asked for - /// its panes' shells in the same round it arrived — and a session that was - /// started with panes and no frontend (which is every `--detach`) is a - /// session whose panes get their shells from the first attach rather than - /// never. See `Shell`. - fn flushOwed(s: *Session) void { - const c = s.primary() orelse return; - const slot = s.slotOf(c); - for (&s.shells, 0..) |*sh, pane| { - if (!sh.owed) continue; - sh.owed = false; - sh.owner = slot; - s.send(c, .{ .spawn = .{ .pane = @intCast(pane), .cwd = sh.cwd.items } }); - // The send closed it, and `close` put its panes back on the owed - // list; the ones this loop has not reached are still owed anyway. - if (c.fd < 0) return; - } + return regridded; } // ---- bytes ------------------------------------------------------------ @@ -902,11 +1563,12 @@ pub const Session = struct { const want = wire.serverBound(msg); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); const bytes = wire.encodeServer(s.scratch.allocatedSlice()[0..want], msg) catch |err| { - // The only reachable case is a payload past `max_payload`: a save - // of a pane holding more text than this protocol carries. The - // session keeps it (the core's own filesystem already has it) and - // the frontend's copy does not happen — said out loud rather than - // silently. + // The only reachable case is a payload past `max_payload`, and with + // every effect that carried a whole file gone from this protocol the + // only payload that can still get there is a yank of more than + // 16 MiB. The session keeps it — `setClipboard` put it in the core's + // own clipboard before this was ever queued — and the frontends' + // desktop clipboards do not get it, out loud rather than silently. log.debug("message {t} not encodable: {t}", .{ msg, err }); return; }; @@ -918,7 +1580,8 @@ pub const Session = struct { // what this refuses is a client that has stopped draining. if (c.out.items.len > out_backlog) return s.close(c, .backlog); // ...and the table as a whole, which `out_backlog` does not bound: 32 - // slots one byte under it each is 32 MiB. See `session_backlog`. + // slots one byte under it each, plus a frame apiece. See + // `session_backlog`. s.account(); if (c.fd < 0) return; // the fattest peer was this one c.out.appendSlice(s.gpa, bytes) catch return s.close(c, .oom); @@ -933,13 +1596,23 @@ pub const Session = struct { /// peer holding the most of it is the peer that stopped reading. The next /// append asks again, so a second offender is closed a message later rather /// than in a loop that could empty the table on one bad frame. + /// + /// `items.len` and NOT `capacity`, which was half of the bug in + /// `session_backlog`'s history. An ArrayList grows geometrically, so a + /// client's `in.capacity` crossed a 4 MiB ceiling while it was still + /// assembling a paste of roughly 2.8 MiB — the peer was punished for the + /// allocator's rounding rather than for anything it held. What this is + /// asking is "how much is a peer making this session hold RIGHT NOW", and + /// that is `items.len`; capacity above it is transient by construction, + /// because `retire` hands back anything over `idle_retain` the moment a + /// buffer empties. fn account(s: *Session) void { var total: usize = 0; var worst: ?*Client = null; var worst_bytes: usize = 0; for (&s.clients) |*c| { if (c.fd < 0) continue; - const held = c.in.capacity + c.out.capacity; + const held = c.in.items.len + c.out.items.len; total += held; if (held > worst_bytes) { worst_bytes = held; @@ -998,8 +1671,9 @@ pub const Session = struct { } /// Free one slot. A frontend dying takes NOTHING with it: not the core, not - /// the listener, not another frontend's frames. Its buffers go back and the - /// slot is reusable on the next connect. + /// the listener, not another frontend's frames, and — since this file + /// forks — not its panes' shells either. Its buffers go back and the slot is + /// reusable on the next connect. fn close(s: *Session, c: *Client, why: Closed) void { if (c.fd < 0) return; log.debug("frontend detached: {t}", .{why}); @@ -1013,19 +1687,12 @@ pub const Session = struct { if (s.origin) |i| if (i == gone) { s.origin = null; }; - // ...and its panes' shells died with the process that forked them. They - // go back on the owed list, so the frontend that replaces this one is - // asked to fork them again in the directory they were forked in: the - // alternative — which is what this did — is a pane that looks alive, - // produces nothing, and swallows everything typed into it. Migrating a - // live pty between processes is the other answer and is a different - // feature; a fresh shell is the one this transport can keep. - for (&s.shells) |*sh| { - const owner = sh.owner orelse continue; - if (owner != gone) continue; - sh.owner = null; - sh.owed = true; - } + // ...and that is the whole of it. A frontend used to take its panes' + // shells with it and leave them owed to whoever attached next, because + // the ptys were in its process; a pane that survived a detach looked + // alive, produced nothing and swallowed everything typed into it. The + // shells are here now, so a frontend leaving is a screen going away and + // nothing else. c.* = .{}; } }; @@ -1038,12 +1705,14 @@ pub const Session = struct { /// core's own `pump`, exactly as the tty and gui shells run it — this frontend /// simply has no window of its own. /// -/// The pre-loop effect drain is here for the same reason tty.zig has one: the -/// startup spawns are already queued, and they have to be PERFORMED before the -/// loop rather than left in the queue. They reach no frontend — there is none -/// yet — and are remembered instead, then asked of the first attach; `Shell` -/// says why that is the only shape that works for a session whose panes exist -/// before its socket does. +/// The pre-loop effect drain is here for the same reason tty.zig has one, and +/// it is no longer half a promise: the startup spawns are already queued and are +/// PERFORMED here, on this process's own process table. So a session binds its +/// socket with every pane's shell already forked and already in the poll set, +/// and the first frontend to attach — whether that is a second later or the +/// next morning — is sent a frame of shells that have been printing into the +/// core since before it existed. Nothing is remembered for a later frontend, +/// because nothing is owed to one. pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void { const gpa = init.gpa; const allocs = pardes.allocators.init(gpa); @@ -1066,13 +1735,23 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void } const core = if (options.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try look.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); - var session: Session = .{ .gpa = gpa, .core = core, .cols = options.cols, .rows = options.rows }; + var session: Session = .{ + .gpa = gpa, + .io = init.io, + .core = core, + .cols = options.cols, + .rows = options.rows, + // Staged before the first fork and owned by the Session for exactly as + // long as it can fork: `shell_bin.resolve` hands a child pointers into + // these buffers, and the child holds them until it execs. + .prompt_rcs = .init(), + }; defer session.deinit(); if (!session.listen(name)) { // Loud, and on stderr rather than through the log: a `--detach` whose @@ -1085,6 +1764,9 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void const h = session.host(); core.host = h; while (core.nextEffect()) |effect| core.perform(effect); + // Past the startup drain: a `ThemeFile` reload from here on is a human's + // and animates. See `in_loop`. + session.in_loop = true; while (!core.quit) try core.pump(h); } @@ -1137,7 +1819,14 @@ fn retire(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged(u8)) void { /// Zero on failure, and every caller treats zero as "no clock" and enforces no /// deadline at all — a session that cannot read a clock keeps every slot rather /// than dropping every slot. -fn monotonicMs() i64 { +/// +/// `pub` for the same reason `setNonblock`, `nosignal` and the `poll_*` +/// constants are: this file owns the transport's conventions and BOTH ends of +/// it, and the clock a handshake is timed against is one of them. client.zig +/// times its wait for a `welcome` on this and against +/// `greet_deadline_default_ms`, so the two ends cannot disagree about how long +/// the handshake is allowed to take. +pub fn monotonicMs() i64 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); @@ -1254,8 +1943,8 @@ fn alive(path: [:0]const u8) bool { } /// Unlink the sockets of detached sessions that are gone — our own litter, -/// which `--attach`'s "the one session there is" would otherwise count as a -/// session (tty.zig `sessionName`). `alive` is the whole of the judgement. +/// which the bare `Attach`'s "whichever session is there" would otherwise count +/// as a session (client.zig `resolve`). `alive` is the whole of the judgement. /// /// Bounded: one readdir of a directory only we write to, one connect each. fn sweep(dir: [:0]const u8) void { diff --git a/src/detached/wire.zig b/src/detached/wire.zig index f2030e26..a84c2a68 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -39,8 +39,23 @@ //! frontend must not have to have been built with the core's options — so it is //! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it. //! -//! WHAT IS NOT HERE. The seam has twenty methods; this carries twelve of -//! them, and the eight it does not are named here with their reasons. +//! WHAT IS NOT HERE. The seam has twenty-one methods; this carries FIVE of +//! them — `push_present` as `frame`, `push_set_clipboard`, +//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the sixteen +//! it does not are named here with their reasons. The five are spelled out +//! because this arithmetic has now gone stale twice in one day, once when the +//! machine-local eight moved into the daemon and once when `detach` arrived, +//! and a count nobody can check against a list is a comment that rots quietly. +//! * The eight machine-local ones — `push_spawn`, `push_pty_write`, +//! `push_pty_resize`, `push_write_file`, `push_write_dump`, +//! `push_watch_file`, `push_watch_theme`, `push_dump_themes` — are +//! performed by the detached core ITSELF, through `host_io.zig`. A unix +//! socket means it is on the same machine, so there is no question of +//! whose disk or whose process table is meant, and a pane's shell has to +//! outlive the frontend that asked for it or a detached session is a +//! promise it cannot keep. The `ServerTag` doc below carries the whole of +//! that argument; this line exists so the count at the top of the file +//! agrees with it. //! * `pull_wait_input` IS the server's poll loop, not a message. //! * `push_poll_frame` and `push_post_present` carry no information. They are //! per-frame bookkeeping ticks, and `frame` already arrives exactly once @@ -114,13 +129,11 @@ const run_header = 4 + 2; const frame_head = 1 + 2 + 2 + 6 + 4; /// The longest legal payload, and therefore the length prefix a decoder will -/// accept before it refuses the stream. Three messages set it: +/// accept before it refuses the stream. Two messages set it: /// * a full frame of the largest grid, worst case one run per cell: /// 512*128 * (6 + 20) = 1.6 MiB. /// * one paste, which the tty frontend already caps at 4 MiB (tty.zig /// `max_paste_bytes`) on the grounds that anything larger is a mis-click. -/// * a `write_file`, whose bytes are a pane's whole text and are the only -/// genuinely open-ended payload here. /// 16 MiB is past every source file anyone edits in this editor and is still a /// buffer the receiving side can simply hold. A larger message is not sent and /// a larger prefix is not read. @@ -151,6 +164,26 @@ pub fn frameBound(cols: u16, rows: u16) usize { /// /// The numbers are the PROTOCOL's, grouped session/input rather than derived /// from `Event`'s declaration order, so reordering the union changes nothing. +/// +/// EVERY TAG HERE IS SOMETHING A HUMAN DID, and that is the whole set: a +/// handshake, a goodbye, and what a keyboard, a mouse, a trackpad or a window +/// manager produces. Six numbers are missing from the input run — 0x13..0x17 +/// and 0x1e — and the gaps are left rather than tidied away, because +/// renumbering is a change every deployed frontend feels. They were `output`, +/// `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and `tick`: the +/// MACHINE-LOCAL host's own reports, which stopped being a frontend's business +/// when the daemon took the disk and the process table (host_io.zig, +/// file_watch.zig). No frontend ever produced one — tty.zig's attached loop +/// swallowed them by name and gui.zig never handed `Input.post` one — and +/// leaving them DECODABLE was not merely dead weight: server.zig's `apply` +/// routes any decoded non-resize event straight into `core.update`, so an +/// attached peer could forge a pane's output, forge an `eof` for a shell that +/// was still running (and unlike the daemon's own `paneEof` the wire path never +/// called `closePty`, so the master stayed open and the shell was orphaned for +/// the life of the session), or replace a pane's text with bytes the next +/// `Save` would write to disk. client.zig's header says a machine-local effect +/// cannot return to the wire; deleting these is what makes that true in BOTH +/// directions instead of only core -> frontend. pub const ClientTag = enum(u8) { hello = 0x01, bye = 0x02, @@ -158,40 +191,46 @@ pub const ClientTag = enum(u8) { key = 0x10, mouse = 0x11, resize = 0x12, - output = 0x13, - eof = 0x14, - lsp_resp = 0x15, - pipe_resp = 0x16, - file_changed = 0x17, paste = 0x18, command = 0x19, pdf_scroll = 0x1a, pinch = 0x1b, touch_scroll = 0x1c, pointer_leave = 0x1d, - tick = 0x1e, }; -/// Core -> frontend. 0x01..0x0f is the session, 0x10.. is one `push_` method +/// Core -> frontend. 0x01..0x0f is the session; 0x10.. is one `push_` method /// each, in `Host.VTable`'s own order so the two lists can be read side by /// side. +/// +/// There are only THREE of those left, and which three is the whole design. +/// The daemon performs every effect that needs a disk or a process table +/// itself (see `host_io.zig`): a unix socket means it is on the same machine, +/// so there is no question of whose disk is meant, and a pane's shell has to +/// outlive the frontend that asked for it or a detached session is a promise +/// it cannot keep. What is left on the wire is what a process nobody is +/// looking at genuinely cannot do — put something on THIS human's clipboard, +/// read it back, and open a link in front of the person who clicked it. +/// +/// `detach` is in the SESSION range and not among those three on purpose: it is +/// not an effect the core wants performed, it is the session telling one +/// frontend that it is done. `quit` is its sibling — same shape, opposite +/// meaning about whether anything survives. pub const ServerTag = enum(u8) { welcome = 0x01, refuse = 0x02, frame = 0x03, quit = 0x04, + /// One frontend is done, and the session is NOT over. The `Detach` word, + /// routed back to the frontend whose keystroke ran it (server.zig ORIGIN, + /// ELSE PRIMARY, the same rule `read_clipboard` takes and for the same + /// reason). Every other frontend, the core and the pane shells are + /// untouched, so leaving is success rather than a failure to report. + detach = 0x05, - spawn = 0x10, - pty_write = 0x11, - pty_resize = 0x12, - write_file = 0x13, - write_dump = 0x14, - watch_file = 0x15, - watch_theme = 0x16, - dump_themes = 0x17, - set_clipboard = 0x18, - read_clipboard = 0x19, - open_link = 0x1a, + set_clipboard = 0x10, + read_clipboard = 0x11, + open_link = 0x12, }; /// Why the server hung up on a connect. Sent as a `refuse` and followed by a @@ -254,6 +293,23 @@ pub const Hello = struct { rows: u16, }; +/// `Hello.version` alone, read out of the still-undecoded payload. +/// +/// Separate from `decodeClient` because of the guarantee the fixed offset above +/// exists to give: a decoder that validates `cols` and `rows` and then refuses +/// trailing bytes can never deliver it. A v2 hello with one more field would be +/// closed as a malformed message, and the `Refusal.version` byte a frontend +/// needs in order to say something true would never be sent — which is exactly +/// the case the field was put at offset zero for. So the version is asked for +/// first, on its own, before any of the layout that may have moved. +/// +/// An error rather than a zero on a payload shorter than two bytes, so a +/// truncated hello stays diagnosable too instead of reading as version 0. +pub fn helloVersion(payload: []const u8) Error!u16 { + var r: Reader = .init(payload); + return r.getU16(); +} + pub const Welcome = struct { version: u16 = version, /// Which client slot this connection got. Carried because it is what the @@ -324,28 +380,14 @@ pub const ServerMsg = union(enum) { /// The session is over. Sent before the listener closes so a frontend can /// exit rather than report a broken pipe. quit, + /// One frontend is done, and the session is NOT over — see `ServerTag`. + detach, - spawn: struct { pane: u8, cwd: []const u8 }, - pty_write: struct { pane: u8, bytes: []const u8 }, - pty_resize: struct { pane: u8, cols: u16, rows: u16 }, - write_file: struct { pane: u8, path: []const u8, bytes: []const u8 }, - write_dump: []const u8, - watch_file: struct { pane: u8, path: []const u8, on: bool }, - watch_theme: struct { generation: u32, on: bool }, - dump_themes: struct { pane: u8 }, set_clipboard: []const u8, read_clipboard, open_link: []const u8, }; -/// The one thing a decoder cannot put in a byte buffer: `pipe_resp.outputs` is -/// a `[]const []const u8`, so the outer array needs somewhere to live. Sized -/// from the core's own ceiling on selections (`MAX_SELS`), which is what bounds -/// the count a legitimate `pipe_resp` can carry. -pub const Scratch = struct { - outputs: [pardes.MAX_SELS][]const u8 = undefined, -}; - // --------------------------------------------------------------------------- // primitives // --------------------------------------------------------------------------- @@ -796,10 +838,16 @@ fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i fn clientTag(msg: ClientMsg) ClientTag { return switch (msg) { .event => |ev| switch (ev) { - // Not on the wire, and not an omission: see the module header. - // The acme mount lives with the core, so this event is raised in - // the same process that answers it and never crosses a socket. - .fs_req => unreachable, + // SEVEN `Event`s a frontend cannot produce, so no `ClientTag` + // exists for them and this arm is where the compiler says so. + // `fs_req` is the acme mount, raised in the same process that + // answers it. The other six are the machine-local host's own + // reports — a pty's output and its EOF, a language or pipe worker's + // answer, a watched file's new bytes, an animation tick — and after + // the daemon took the disk and the process table every one of them + // is raised by the process that already holds the core. See + // `ClientTag` for what putting them back would let a peer forge. + .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, inline else => |_, t| @field(ClientTag, @tagName(t)), }, inline else => |_, t| @field(ClientTag, @tagName(t)), @@ -849,26 +897,6 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.w else 8); try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.h else 16); }, - .output => |o| { - try w.putByte(o.pane); - try w.putSlice32(o.bytes); - }, - .eof => |e| try w.putByte(e.pane), - .lsp_resp => |l| { - try w.putU32(l.id); - try w.putSlice32(l.rows); - }, - .pipe_resp => |p| { - try w.putU32(p.id); - try w.putBool(p.success); - if (p.outputs.len > pardes.MAX_SELS) return error.Overlong; - try w.putU16(@intCast(p.outputs.len)); - for (p.outputs) |o| try w.putSlice32(o); - }, - .file_changed => |f| { - try w.putByte(f.pane); - try w.putSlice32(f.bytes); - }, .paste => |b| try w.putSlice32(b), .command => |line| try w.putSlice16(line), .pdf_scroll => |s| { @@ -877,15 +905,16 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { }, .pinch => |v| try w.putF32(v), .touch_scroll => |v| try w.putF32(v), - .pointer_leave, .tick => {}, - .fs_req => unreachable, + .pointer_leave => {}, + // See `clientTag`: no tag, so nothing to encode. + .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, }, } try finishMessage(&w, at); return w.written(); } -pub fn decodeClient(tag: u8, payload: []const u8, scratch: *Scratch) Error!ClientMsg { +pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg { var r: Reader = .init(payload); const msg: ClientMsg = switch (std.enums.fromInt(ClientTag, tag) orelse return error.BadTag) { .hello => .{ .hello = .{ @@ -928,29 +957,12 @@ pub fn decodeClient(tag: u8, payload: []const u8, scratch: *Scratch) Error!Clien if (comptime has_cell_pixels) ev.resize.cell_pixels = .{ .w = px_w, .h = px_h }; break :blk .{ .event = ev }; }, - .output => .{ .event = .{ .output = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } }, - .eof => .{ .event = .{ .eof = .{ .pane = try r.getPane() } } }, - .lsp_resp => .{ .event = .{ .lsp_resp = .{ .id = try r.getU32(), .rows = try r.getSlice32() } } }, - .pipe_resp => blk: { - const id = try r.getU32(); - const success = try r.getBool(); - const n = try r.getU16(); - if (n > scratch.outputs.len) return error.Overlong; - for (scratch.outputs[0..n]) |*o| o.* = try r.getSlice32(); - break :blk .{ .event = .{ .pipe_resp = .{ - .id = id, - .success = success, - .outputs = scratch.outputs[0..n], - } } }; - }, - .file_changed => .{ .event = .{ .file_changed = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } }, .paste => .{ .event = .{ .paste = try r.getSlice32() } }, .command => .{ .event = .{ .command = try r.getSlice16() } }, .pdf_scroll => .{ .event = .{ .pdf_scroll = .{ .pane = try r.getPane(), .delta_pixels = try r.getF32() } } }, .pinch => .{ .event = .{ .pinch = try r.getF32() } }, .touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } }, .pointer_leave => .{ .event = .pointer_leave }, - .tick => .{ .event = .tick }, }; try r.end(); return msg; @@ -984,36 +996,7 @@ pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 { // encodeFrame directly and this arm exists so the switch stays // exhaustive over ServerMsg. .frame => return error.BadValue, - .quit => {}, - .spawn => |s| { - try w.putByte(s.pane); - try w.putSlice16(s.cwd); - }, - .pty_write => |p| { - try w.putByte(p.pane); - try w.putSlice32(p.bytes); - }, - .pty_resize => |p| { - try w.putByte(p.pane); - try w.putU16(p.cols); - try w.putU16(p.rows); - }, - .write_file => |f| { - try w.putByte(f.pane); - try w.putSlice16(f.path); - try w.putSlice32(f.bytes); - }, - .write_dump => |b| try w.putSlice32(b), - .watch_file => |v| { - try w.putByte(v.pane); - try w.putSlice16(v.path); - try w.putBool(v.on); - }, - .watch_theme => |t| { - try w.putU32(t.generation); - try w.putBool(t.on); - }, - .dump_themes => |d| try w.putByte(d.pane), + .quit, .detach => {}, .set_clipboard => |t| try w.putSlice32(t), .read_clipboard => {}, .open_link => |u| try w.putSlice16(u), @@ -1033,14 +1016,9 @@ const msg_slack = header_len + 32; /// once instead of guessing and retrying. pub fn serverBound(msg: ServerMsg) usize { return msg_slack + switch (msg) { - .welcome, .refuse, .quit, .pty_resize, .watch_theme, .dump_themes, .read_clipboard => 0, + .welcome, .refuse, .quit, .detach, .read_clipboard => 0, // A frame is bounded by its grid, not by this: see `frameBound`. .frame => |f| frameBound(f.cols, f.rows), - .spawn => |s| s.cwd.len, - .pty_write => |p| p.bytes.len, - .write_file => |f| f.path.len + f.bytes.len, - .write_dump => |b| b.len, - .watch_file => |v| v.path.len, .set_clipboard => |t| t.len, .open_link => |u| u.len, }; @@ -1051,21 +1029,12 @@ pub fn clientBound(msg: ClientMsg) usize { return msg_slack + switch (msg) { .hello, .bye => 0, .event => |ev| switch (ev) { - .mouse, .resize, .eof, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .tick => 0, + .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0, .key => |k| k.text.len, - .output => |o| o.bytes.len, - .lsp_resp => |l| l.rows.len, - .pipe_resp => |p| blk: { - // Each output carries its own u32 prefix, so the count is part - // of the bound and not just the bytes. - var total: usize = p.outputs.len * 4; - for (p.outputs) |o| total += o.len; - break :blk total; - }, - .file_changed => |f| f.bytes.len, .paste => |b| b.len, .command => |line| line.len, - .fs_req => unreachable, + // See `clientTag`: not on this wire in this direction. + .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, }, }; } @@ -1103,26 +1072,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { } }; }, .quit => .quit, - .spawn => .{ .spawn = .{ .pane = try r.getPane(), .cwd = try r.getSlice16() } }, - .pty_write => .{ .pty_write = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } }, - .pty_resize => .{ .pty_resize = .{ - .pane = try r.getPane(), - .cols = try r.getU16(), - .rows = try r.getU16(), - } }, - .write_file => .{ .write_file = .{ - .pane = try r.getPane(), - .path = try r.getSlice16(), - .bytes = try r.getSlice32(), - } }, - .write_dump => .{ .write_dump = try r.getSlice32() }, - .watch_file => .{ .watch_file = .{ - .pane = try r.getPane(), - .path = try r.getSlice16(), - .on = try r.getBool(), - } }, - .watch_theme => .{ .watch_theme = .{ .generation = try r.getU32(), .on = try r.getBool() } }, - .dump_themes => .{ .dump_themes = .{ .pane = try r.getPane() } }, + .detach => .detach, .set_clipboard => .{ .set_clipboard = try r.getSlice32() }, .read_clipboard => .read_clipboard, .open_link => .{ .open_link = try r.getSlice16() }, @@ -1144,11 +1094,11 @@ const testing = std.testing; /// Round-trip one frontend -> core message through the framing too, so a /// length prefix that disagrees with the payload cannot pass. -fn roundClient(buf: []u8, msg: ClientMsg, scratch: *Scratch) !ClientMsg { +fn roundClient(buf: []u8, msg: ClientMsg) !ClientMsg { const bytes = try encodeClient(buf, msg); const f = (try framed(bytes)).?; try testing.expectEqual(bytes.len, f.total); - return decodeClient(f.tag, f.payload, scratch); + return decodeClient(f.tag, f.payload); } fn roundServer(buf: []u8, msg: ServerMsg) !ServerMsg { @@ -1160,26 +1110,25 @@ fn roundServer(buf: []u8, msg: ServerMsg) !ServerMsg { test "detached wire: every Event variant round-trips" { var buf: [4096]u8 = undefined; - var scratch: Scratch = .{}; // The tag space is the protocol's own, so assert the numbers themselves: // a renumbering here breaks every deployed frontend and must be a diff // somebody reads, not a silent change. try testing.expectEqual(@as(u8, 0x01), @intFromEnum(ClientTag.hello)); try testing.expectEqual(@as(u8, 0x10), @intFromEnum(ClientTag.key)); - try testing.expectEqual(@as(u8, 0x1e), @intFromEnum(ClientTag.tick)); + try testing.expectEqual(@as(u8, 0x1d), @intFromEnum(ClientTag.pointer_leave)); { - const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }, &scratch); + const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }); try testing.expectEqual(version, got.hello.version); try testing.expectEqual(@as(u16, 80), got.hello.cols); try testing.expectEqual(@as(u16, 24), got.hello.rows); } - try testing.expectEqual(ClientMsg.bye, try roundClient(&buf, .bye, &scratch)); + try testing.expectEqual(ClientMsg.bye, try roundClient(&buf, .bye)); { const key: pardes.Key = .{ .cp = pardes.Key.page_down, .text = "ü", .ctrl = true, .alt = false, .shift = true }; - const got = (try roundClient(&buf, .{ .event = .{ .key = key } }, &scratch)).event.key; + const got = (try roundClient(&buf, .{ .event = .{ .key = key } })).event.key; try testing.expectEqual(key.cp, got.cp); try testing.expectEqualStrings(key.text, got.text); try testing.expectEqual(key.ctrl, got.ctrl); @@ -1192,7 +1141,7 @@ test "detached wire: every Event variant round-trips" { for (std.enums.values(pardes.Mouse.Button)) |button| { for (std.enums.values(pardes.Mouse.Kind)) |kind| { const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true }; - const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } }, &scratch)).event.mouse; + const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } })).event.mouse; try testing.expectEqual(m.button, got.button); try testing.expectEqual(m.kind, got.kind); try testing.expectEqual(m.col, got.col); @@ -1202,7 +1151,7 @@ test "detached wire: every Event variant round-trips" { } } { - const got = (try roundClient(&buf, .{ .event = .{ .resize = .{ .cols = 56, .rows = 14 } } }, &scratch)).event.resize; + const got = (try roundClient(&buf, .{ .event = .{ .resize = .{ .cols = 56, .rows = 14 } } })).event.resize; try testing.expectEqual(@as(u16, 56), got.cols); try testing.expectEqual(@as(u16, 14), got.rows); // Pixels travel whether or not this build has them; where it does, the @@ -1212,48 +1161,18 @@ test "detached wire: every Event variant round-trips" { try testing.expectEqual(@as(u16, 16), got.cell_pixels.h); } } + try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } })).event.paste); + try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } })).event.command); { - const got = (try roundClient(&buf, .{ .event = .{ .output = .{ .pane = 3, .bytes = "hi\x00there" } } }, &scratch)).event.output; - try testing.expectEqual(@as(u8, 3), got.pane); - try testing.expectEqualStrings("hi\x00there", got.bytes); - } - try testing.expectEqual(@as(u8, 15), (try roundClient(&buf, .{ .event = .{ .eof = .{ .pane = 15 } } }, &scratch)).event.eof.pane); - { - const got = (try roundClient(&buf, .{ .event = .{ .lsp_resp = .{ .id = 0xdeadbeef, .rows = "a:1:2-3 x" } } }, &scratch)).event.lsp_resp; - try testing.expectEqual(@as(u32, 0xdeadbeef), got.id); - try testing.expectEqualStrings("a:1:2-3 x", got.rows); - } - { - // Including an EMPTY output, which is what a filter that consumed a - // selection and printed nothing returns. - const outputs: []const []const u8 = &.{ "AA\n", "", "cc" }; - const got = (try roundClient(&buf, .{ .event = .{ .pipe_resp = .{ .id = 9, .success = true, .outputs = outputs } } }, &scratch)).event.pipe_resp; - try testing.expectEqual(@as(u32, 9), got.id); - try testing.expect(got.success); - try testing.expectEqual(@as(usize, 3), got.outputs.len); - for (outputs, got.outputs) |want, have| try testing.expectEqualStrings(want, have); - } - { - const got = (try roundClient(&buf, .{ .event = .{ .file_changed = .{ .pane = 0, .bytes = "" } } }, &scratch)).event.file_changed; - try testing.expectEqual(@as(u8, 0), got.pane); - try testing.expectEqualStrings("", got.bytes); - } - try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } }, &scratch)).event.paste); - try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } }, &scratch)).event.command); - { - const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } }, &scratch)).event.pdf_scroll; + const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } })).event.pdf_scroll; try testing.expectEqual(@as(u8, 2), got.pane); try testing.expectEqual(@as(f32, -12.5), got.delta_pixels); } - try testing.expectEqual(@as(f32, 1.25), (try roundClient(&buf, .{ .event = .{ .pinch = 1.25 } }, &scratch)).event.pinch); - try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } }, &scratch)).event.touch_scroll); + try testing.expectEqual(@as(f32, 1.25), (try roundClient(&buf, .{ .event = .{ .pinch = 1.25 } })).event.pinch); + try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } })).event.touch_scroll); try testing.expectEqual( std.meta.Tag(pardes.Event).pointer_leave, - (try roundClient(&buf, .{ .event = .pointer_leave }, &scratch)).event, - ); - try testing.expectEqual( - std.meta.Tag(pardes.Event).tick, - (try roundClient(&buf, .{ .event = .tick }, &scratch)).event, + (try roundClient(&buf, .{ .event = .pointer_leave })).event, ); } @@ -1270,44 +1189,91 @@ test "detached wire: every server message round-trips" { for (std.enums.values(Refusal)) |why| try testing.expectEqual(why, (try roundServer(&buf, .{ .refuse = why })).refuse); try testing.expectEqual(ServerMsg.quit, try roundServer(&buf, .quit)); - { - const got = (try roundServer(&buf, .{ .spawn = .{ .pane = 1, .cwd = "/home/x" } })).spawn; - try testing.expectEqual(@as(u8, 1), got.pane); - try testing.expectEqualStrings("/home/x", got.cwd); - } - { - const got = (try roundServer(&buf, .{ .pty_write = .{ .pane = 1, .bytes = "ls\r" } })).pty_write; - try testing.expectEqual(@as(u8, 1), got.pane); - try testing.expectEqualStrings("ls\r", got.bytes); - } - { - const got = (try roundServer(&buf, .{ .pty_resize = .{ .pane = 1, .cols = 80, .rows = 24 } })).pty_resize; - try testing.expectEqual(@as(u16, 80), got.cols); - try testing.expectEqual(@as(u16, 24), got.rows); - } - { - const got = (try roundServer(&buf, .{ .write_file = .{ .pane = 4, .path = "/tmp/a", .bytes = "body\n" } })).write_file; - try testing.expectEqual(@as(u8, 4), got.pane); - try testing.expectEqualStrings("/tmp/a", got.path); - try testing.expectEqualStrings("body\n", got.bytes); - } - try testing.expectEqualStrings(".{}", (try roundServer(&buf, .{ .write_dump = ".{}" })).write_dump); - { - const got = (try roundServer(&buf, .{ .watch_file = .{ .pane = 0, .path = "/tmp/b", .on = true } })).watch_file; - try testing.expectEqualStrings("/tmp/b", got.path); - try testing.expect(got.on); - } - { - const got = (try roundServer(&buf, .{ .watch_theme = .{ .generation = 7, .on = false } })).watch_theme; - try testing.expectEqual(@as(u32, 7), got.generation); - try testing.expect(!got.on); - } - try testing.expectEqual(@as(u8, 5), (try roundServer(&buf, .{ .dump_themes = .{ .pane = 5 } })).dump_themes.pane); + try testing.expectEqual(ServerMsg.detach, try roundServer(&buf, .detach)); try testing.expectEqualStrings("yank", (try roundServer(&buf, .{ .set_clipboard = "yank" })).set_clipboard); try testing.expectEqual(ServerMsg.read_clipboard, try roundServer(&buf, .read_clipboard)); try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link); } +test "detached wire: only the display's own effects are on the wire" { + // Two guards, because the mistake has two directions. The exhaustive + // switch fails to COMPILE if a machine-local effect is added back, which + // is the direction that matters: it would hand a frontend work that must + // outlive it. The count fails if one of the three is dropped, which would + // silently leave a clipboard or a link unanswered on every frontend. + try testing.expectEqual(@as(usize, 8), std.enums.values(ServerTag).len); + for (std.enums.values(ServerTag)) |t| switch (t) { + .welcome, .refuse, .frame, .quit, .detach, .set_clipboard, .read_clipboard, .open_link => {}, + }; +} + +test "detached wire: a session is never told to do a frontend's remembering" { + // The mirror of the test above, and of client.zig's "a frontend is never + // asked to fork, write, or watch". That one pins what may reach a FRONTEND; + // this one pins what may reach the SESSION, and until the six tags below it + // names were deleted the protocol was asymmetric: server -> client carried + // only what a display can do, while client -> server still carried the + // machine-local host's own reports, which server.zig's `apply` hands + // straight to `core.update`. + // + // Adding a name here is meant to be an ARGUMENT, not a formality, and the + // bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a + // window resized, a paste, a command line executed, a pointer leaving the + // window — plus the two session words that say who is speaking. A pty's + // output, a worker's answer, a watched file's new bytes and an animation + // tick all fail that bar the same way: nobody did them, a machine reported + // them, and the machine that reports them is the one already holding the + // core. + const allowed = [_][]const u8{ + // Who this connection is, and that it is finished. + "hello", "bye", + // ...and everything a person can do to a window. + "key", "mouse", + "resize", "paste", + "command", "pdf_scroll", + "pinch", "touch_scroll", + "pointer_leave", + }; + + // One: the tag set is exactly that, named rather than counted, so + // re-adding `output` fails with the name in the failure. + inline for (std.enums.values(ClientTag)) |t| { + for (allowed) |ok| { + if (std.mem.eql(u8, @tagName(t), ok)) break; + } else { + std.debug.print("ClientTag.{s} is not something a human did\n", .{@tagName(t)}); + return error.MachineLocalReportOnTheWire; + } + } + try testing.expectEqual(allowed.len, std.enums.values(ClientTag).len); + + // Two: and no tag BYTE outside them decodes either — the check above is + // about this build's enum, this one is about the bytes on the socket. The + // six deleted numbers (0x13..0x17, 0x1e) are in the 250 that must answer + // `BadTag`, so a frontend built before this change cannot forge a pane's + // output into a session built after it. + var accepted: usize = 0; + for (0..256) |i| { + const tag: u8 = @intCast(i); + // Empty payloads on purpose: what is asked is whether the TAG is known. + // A known one fails later (`Truncated`) or succeeds, never with + // `BadTag`. + if (decodeClient(tag, &.{})) |_| accepted += 1 else |err| switch (err) { + error.BadTag => continue, + else => accepted += 1, + } + const named = for (allowed) |ok| { + const want = std.meta.stringToEnum(ClientTag, ok).?; + if (@intFromEnum(want) == tag) break true; + } else false; + if (!named) { + std.debug.print("tag 0x{x:0>2} is decodable by a session and is not something a human did\n", .{tag}); + return error.MachineLocalReportOnTheWire; + } + } + try testing.expectEqual(allowed.len, accepted); +} + /// A grid with something in every corner: a default cell, a plain ASCII cell, /// an indexed pair, an rgb pair with every attribute on, and a multi-byte /// grapheme — the five shapes `putCell` branches on. @@ -1481,7 +1447,6 @@ test "detached wire: a run is coalesced across a gap only when that is cheaper" test "detached wire: a truncated frame is refused at every length" { var buf: [4096]u8 = undefined; - var scratch: Scratch = .{}; // Every prefix of a real message. The framing must say "not yet" for the // ones that are short, and the decoder must say "truncated" for a payload @@ -1494,22 +1459,21 @@ test "detached wire: a truncated frame is refused at every length" { // ...and the same bytes handed to the decoder with the header's length // left claiming the whole message, which is how a decoder is walked // off the end of its buffer. - try testing.expectError(error.Truncated, decodeClient(copy[0], copy[header_len..cut], &scratch)); + try testing.expectError(error.Truncated, decodeClient(copy[0], copy[header_len..cut])); } // Shorter than the header itself is not yet a message at all. for (0..header_len + 1) |cut| try testing.expectEqual(@as(?Framed, null), try framed(copy[0..cut])); // A payload with bytes LEFT OVER is refused too: it is not this message. - try testing.expectError(error.Trailing, decodeClient(@intFromEnum(ClientTag.tick), "x", &scratch)); + try testing.expectError(error.Trailing, decodeClient(@intFromEnum(ClientTag.pointer_leave), "x")); try testing.expectError(error.Trailing, decodeServer(@intFromEnum(ServerTag.quit), "x")); } test "detached wire: an unknown tag is refused, never guessed" { - var scratch: Scratch = .{}; // 0x00 and 0xff have never been assigned, and 0x0f sits in the gap between // the session tags and the input tags. All three are the same answer. for ([_]u8{ 0x00, 0x0f, 0x1f, 0xff }) |tag| { - try testing.expectError(error.BadTag, decodeClient(tag, "", &scratch)); + try testing.expectError(error.BadTag, decodeClient(tag, "")); try testing.expectError(error.BadTag, decodeServer(tag, "")); } // A tag NESTED in a payload gets the same treatment: a color, an @@ -1518,7 +1482,6 @@ test "detached wire: an unknown tag is refused, never guessed" { try testing.expectError(error.BadTag, decodeClient( @intFromEnum(ClientTag.mouse), &.{ 0x09, 0x00, 0, 0, 0, 0, 0 }, - &scratch, )); } @@ -1536,14 +1499,13 @@ test "detached wire: an over-long length prefix is refused before it is believed // An INNER length prefix, past the payload it sits in but inside the // protocol's cap — the one an outer-frame check cannot catch. - var scratch: Scratch = .{}; var paste: [8]u8 = undefined; std.mem.writeInt(u32, paste[0..4], 4096, .little); @memcpy(paste[4..8], "abcd"); - try testing.expectError(error.Truncated, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch)); + try testing.expectError(error.Truncated, decodeClient(@intFromEnum(ClientTag.paste), &paste)); // ...and past the cap, which is refused rather than read. std.mem.writeInt(u32, paste[0..4], max_payload + 1, .little); - try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch)); + try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.paste), &paste)); } test "detached wire: a frame that lies about its runs cannot walk out of the grid" { @@ -1664,56 +1626,44 @@ test "detached wire: a cursor outside the grid is refused, not painted" { } test "detached wire: values a field cannot mean are refused" { - var scratch: Scratch = .{}; - // A bool is 0 or 1. `2` used to be "true" in every hand-written codec that // ever silently accepted a corrupt stream. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.key), &.{ 'a', 0, 0, 0, 0, 0, 2, 0, 0 }, - &scratch, )); // A codepoint past Unicode's last: @intCast into Key.cp's u21 would panic. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.key), &.{ 0x00, 0x00, 0x11, 0x00, 0, 0, 0, 0, 0 }, - &scratch, )); - // A pane the core cannot index. + // A pane the core cannot index. `pdf_scroll` reads its pane byte before the + // f32 that follows, so a one-byte payload reaches the check this is about + // rather than tripping `Truncated` first. try testing.expectError(error.BadValue, decodeClient( - @intFromEnum(ClientTag.eof), + @intFromEnum(ClientTag.pdf_scroll), &.{pardes.MAX_PANES}, - &scratch, )); // A zero-column grid would collapse a shared session; an over-wide one is // past what this protocol carries. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.hello), &.{ 1, 0, 0, 0, 24, 0 }, - &scratch, )); { var hello: [6]u8 = undefined; std.mem.writeInt(u16, hello[0..2], version, .little); std.mem.writeInt(u16, hello[2..4], max_cols + 1, .little); std.mem.writeInt(u16, hello[4..6], 24, .little); - try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.hello), &hello, &scratch)); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.hello), &hello)); } // A NaN scroll distance. `pinch` multiplies into a zoom the pane keeps. { var pinch: [4]u8 = undefined; std.mem.writeInt(u32, &pinch, @bitCast(std.math.nan(f32)), .little); - try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.pinch), &pinch, &scratch)); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.pinch), &pinch)); std.mem.writeInt(u32, &pinch, @bitCast(std.math.inf(f32)), .little); - try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch, &scratch)); - } - // More pipe outputs than the core has selections to produce them. - { - var head: [7]u8 = undefined; - std.mem.writeInt(u32, head[0..4], 1, .little); - head[4] = 1; - std.mem.writeInt(u16, head[5..7], pardes.MAX_SELS + 1, .little); - try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.pipe_resp), &head, &scratch)); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch)); } // A cell with the reserved attribute bit set, and one with an impossible // grapheme length. Both are bytes this protocol has no meaning for. |
