summaryrefslogtreecommitdiff
path: root/src/detached
diff options
context:
space:
mode:
Diffstat (limited to 'src/detached')
-rw-r--r--src/detached/client.zig405
-rw-r--r--src/detached/server.zig1169
-rw-r--r--src/detached/wire.zig464
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.