summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig64
-rw-r--r--src/config.zig20
-rw-r--r--src/detached/client.zig405
-rw-r--r--src/detached/server.zig1169
-rw-r--r--src/detached/wire.zig464
-rw-r--r--src/gui/gui.zig976
-rw-r--r--src/host.zig10
-rw-r--r--src/host_io.zig178
-rw-r--r--src/limits.zig15
-rw-r--r--src/macos.zig57
-rw-r--r--src/main.zig59
-rw-r--r--src/nested.zig12
-rw-r--r--src/pardes.zig464
-rw-r--r--src/pdf_pane_integration_test.zig42
-rw-r--r--src/term_pane.zig1632
-rw-r--r--src/tty/tty.zig1036
16 files changed, 5118 insertions, 1485 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 2c52b5f9..0e92f5ce 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -280,6 +280,58 @@ pub const Restore = struct {
}
};
+/// `Attach [name]` — hand this frontend's screen to a detached core, the one
+/// `pardes --detach [name]` left running. Bare, it means "the session that is
+/// there", which is the case worth typing: one detached session, and one word
+/// to walk back into it.
+///
+/// Nothing is torn down HERE, and that is the feature rather than an omission.
+/// The effect only ASKS; the shell connects first and swaps second, so an
+/// Attach that reaches nothing leaves this instance with every pane and every
+/// undo exactly where they were and a line on the message row. Absent where
+/// there is no unix socket to attach to — the browser and the board — and also
+/// absent where the frontend would never NOTICE the request: see
+/// `pardes.can_attach`, which is narrower than `hosted` because macOS never
+/// polls `takeAttach`, so the word would have queued an effect and then done
+/// nothing at all.
+pub const Attach = struct {
+ pub const takes_arg = true;
+ pub const enabled = pardes.can_attach;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) ask(c) else unreachable;
+ }
+ /// A name too long for `Effect.attach` is too long for `sun_path` several
+ /// times over, so it can never name a session: reporting it here is the
+ /// same answer a failed connect gets, one round trip earlier.
+ fn ask(c: Ctx) void {
+ const name = c.arg orelse "";
+ if (name.len > pardes.attach_name_max)
+ return c.p.reportError(c.id, comptime word(@This()), error.NameTooLong);
+ c.p.emit(.{ .attach = .{ .pane = @intCast(c.id), .name = .from(name) } });
+ }
+};
+
+/// `Detach` — leave the session and let it carry on without you, which is
+/// tmux's detach-client. Executed inside an ATTACHED frontend, where it
+/// travels to the daemon as an ordinary command line, is run by the core that
+/// owns the panes, and comes back as the effect that dismisses the screen
+/// which asked for it. Hence no argument: the daemon knows who typed.
+///
+/// It is NOT `Attach` backwards, and no word here is. Making a live local
+/// session outlive its terminal means setsid and a fork; a word that pretended
+/// to would hand you a session that dies with the window it was typed in. Run
+/// locally this therefore REPORTS rather than acts — see the `.detach` arm of
+/// Pardes.perform, which finds no host method to call.
+pub const Detach = struct {
+ pub const enabled = pardes.can_attach;
+ pub fn run(c: Ctx) void {
+ if (comptime enabled)
+ c.p.emit(.{ .detach = .{ .pane = @intCast(c.id) } })
+ else
+ unreachable;
+ }
+};
+
// ---- the message row ----
/// TEXT onto this pane's transient message row — the row a failed save, a
@@ -814,7 +866,17 @@ pub const Last = struct {
while (i > 0) {
i -= 1;
const j = c.p.jumps[i];
- if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col });
+ // `.keep`: Esc is a RETURN, and the pane still holds the view it
+ // was left with. Recentring it moved the whole screen to show a line
+ // that was, nearly always, already on it.
+ //
+ // Not `line = 0`, which focusPaneLine already understands as "focus
+ // and touch nothing": a background pane's view CAN move while you
+ // are away — the wheel scrolls the pane under the pointer, not the
+ // active one, and a resize recomputes geometry without revealing any
+ // cursor — so `.keep` restores the recorded cursor and lets
+ // ensureCursorVisible pull it back on screen by the least it can.
+ if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .keep);
}
}
};
diff --git a/src/config.zig b/src/config.zig
index 15a21866..8d186435 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -228,6 +228,26 @@ pub const leader_path = paths: {
table.set(.ThemeFile, null);
table.set(.DumpThemes, null);
}
+ // ...and the one native word that DOES earn a chord. Gated on `can_attach`
+ // and NOT on `hosted`, because this table may only name a builtin that
+ // exists: macOS is hosted but never polls `takeAttach`, so the two words
+ // below are compiled out there and naming them would be a compile error —
+ // which is the good outcome, and the reason the predicate exists.
+ if (pardes.can_attach) {
+ // In the `s` session group beside Dump and Restore. Bare Attach means
+ // "whichever detached session is there", which is the whole case worth
+ // a key; the named form is typed, like every other builtin that takes
+ // an operand. Safe to press by accident, uniquely among the three: it
+ // connects before it swaps, so nothing to attach to costs you a
+ // message row.
+ table.set(.Attach, "sa");
+ // ...and the way back out, which is where the group runs out of
+ // letters: `sd` has been Dump's since before there was anything to
+ // detach from, and Detach is not worth breaking that muscle memory
+ // for. A capital where the lowercase is taken is what this table
+ // already does one group over (`lS` beside `ls`, `lD` beside `ld`).
+ table.set(.Detach, "sD");
+ }
// The bare-metal memory words. Peek, Poke and Hexdump all take an ADDRESS,
// so none of them can have a leader path for the reason Theme and Msg have
// none: a key path names a builtin and can never carry an operand.
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.
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 05008d0b..c42e390b 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -5,6 +5,26 @@
//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the
//! real renderer, drives input from stdin, and captures frames to PPM.
+//!
+//! ...AND THE SAME WINDOW WITH NO CORE IN IT. `--attach`, and the `Attach`
+//! builtin, hand this window's screen to a detached session (src/detached/):
+//! the `Pardes` lives in THAT process, and this one sends the input it collects
+//! and paints the frames it is sent. The two modes share every line that
+//! touches SDL — `dispatch`/`keyDown` translate an SDL_Event once,
+//! `renderFrame` rasterizes a `Surface` once, `putClipboard`/`takeClipboard`
+//! and `look.openLink` are the desktop once — and differ only in where a
+//! translated event goes and where the cells came from. `Input` is that seam,
+//! and a null `core` inside it is what "attached" MEANS here: every function
+//! that reads pane rects or theme colours off the core takes an optional one
+//! and falls back to the body grid, because the wire carries cells, not the
+//! layout that produced them.
+//!
+//! An attached window does NO machine-local work whatsoever: it forks no shell,
+//! writes no file and watches no path, because the session process owns all of
+//! that now (src/host_io.zig, src/file_watch.zig, src/detached/server.zig).
+//! The only effects still on that wire are the three that need a human's own
+//! display — `set_clipboard`, `read_clipboard`, `open_link` — and those land on
+//! THIS display.
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
@@ -29,16 +49,25 @@ const nested = @import("../nested.zig");
const fuse = @import("../fuse.zig");
const fs_service = @import("../fs_service.zig");
+// The other half of `--detach`, and the reason this file has an `--attach`
+// branch at all: the frontend side of a detached session is a window and a
+// socket, and this file is already the one that owns a window. Just the one
+// import: client.zig owns the frontend's whole side of this transport — the
+// name resolution, the handshake, the poll interval and the decoded messages —
+// so nothing here reaches past it to server.zig or wire.zig.
+const detached_client = @import("../detached/client.zig");
+
pub const c = @cImport({
@cDefine("SDL_DISABLE_OLD_NAMES", "1");
@cInclude("SDL3/SDL.h");
@cInclude("font.h");
});
-extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
-extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
-extern "c" fn chdir(path: [*:0]const u8) c_int;
-extern "c" fn _exit(status: c_int) noreturn;
+// The machine-local half of a host — fork a pane's shell, put bytes on a disk
+// — is shared with the tty shell and the detached daemon. This file used to
+// carry its own `forkShell`, `writeWholeFile` and `writeFd`, ten of eleven
+// lines identical to that file's and one line short of its zero-write guard.
+const host_io = @import("../host_io.zig");
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize)
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
@@ -786,12 +815,12 @@ fn takeScrollTicks(accum: *f32) i32 {
}
/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute.
-fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+fn handleFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
std.debug.assert(cell_w > 0 and cell_h > 0);
- return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h, tagline_w);
+ return handlePairFinger(t, in, f, win_w, win_h, cell_w, cell_h, tagline_w);
}
-fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+fn handlePairFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
switch (f.kind) {
.down => {
t.updatePoint(f);
@@ -803,33 +832,33 @@ fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_
t.syncPair();
if (!had_pair) return;
const res = t.noteMotion(f) orelse return;
- emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h, tagline_w);
+ emitTouchScroll(in, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h, tagline_w);
},
.up, .cancel => {
const tap_center = t.finishPair(f);
t.updatePoint(f);
t.syncPair();
- if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h, tagline_w);
+ if (tap_center) |center| emitTouchClick(t, in, center, .middle, win_w, win_h, cell_w, cell_h, tagline_w);
},
}
}
-fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
- const cell = gridCellAtDimensions(core, center.x * win_w, center.y * win_h, cell_w, cell_h, tagline_w);
+fn emitTouchScroll(in: *Input, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+ const cell = gridCellAtDimensions(in.core, center.x * win_w, center.y * win_h, cell_w, cell_h, tagline_w);
const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down;
var remaining: u32 = @abs(ticks);
while (remaining > 0) : (remaining -= 1)
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
- core.update(.{ .touch_scroll = delta_y });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
+ in.post(.{ .touch_scroll = delta_y });
}
-fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+fn emitTouchClick(t: *Touch, in: *Input, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
t.click_count +%= 1;
t.click_button = button;
t.click_flash_frames = touch_click_flash_max_frames;
- const cell = gridCellAtDimensions(core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w);
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
- core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row } });
+ const cell = gridCellAtDimensions(in.core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w);
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row } });
}
fn normCell(norm: f32, win: f32, cell: f32) u16 {
@@ -1451,6 +1480,24 @@ fn windowGeometry(window: *c.SDL_Window) WindowGeometry {
};
}
+/// This window in whole cells, which is the grid the core is asked to be. The
+/// one derivation of it: `pollFrame` follows the window with it every frame,
+/// `refitFont` re-asks after Ctrl+/Ctrl- has moved the cell under it, and both
+/// attach paths tell the session what this window can show with it. A window
+/// that is not a whole number of cells across has to round the same way in all
+/// four places or the last row lands off the bottom edge.
+const GridCells = struct { cols: u16, rows: u16 };
+
+fn windowCells(g: *const Gui) GridCells {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ return .{
+ .cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))),
+ .rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))),
+ };
+}
+
fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point {
return .{
.x = x * geometry.pixel_w / geometry.window_w,
@@ -1485,30 +1532,40 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout {
};
}
+/// Where a tagline cell's compact band begins. `core` is null in an attached
+/// window, and then EVERY tagline cell takes the last line's fallback: the
+/// wire carries cells, not the pane rects that placed them, so there is no
+/// band origin to compact against. That is the same answer `gridCellAtDimensions`
+/// reaches for the same reason, which is what keeps the two honest — a click
+/// lands on the glyph it was aimed at, because both sides map through the body
+/// grid. The visible cost is one tagline row's worth of loose tracking.
fn taglineLayoutForCell(
g: *const Gui,
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
col: u16,
row: u16,
track: ?pardes.panel_animation.Track,
) CellLayout {
if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0);
- if (track) |active| {
- const box = active.contentBox();
- const tag_y = if (core.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(pardes.BOX_H)) else box.y;
- if (@as(f32, @floatFromInt(row)) >= tag_y and
- @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(pardes.BOX_H)))
- return compactTaglineLayout(g, box.x);
- }
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const r = core.rects[id];
- const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
- if (row == tag_y and col >= r.x and col < r.x + r.w)
- return compactTaglineLayout(g, @floatFromInt(r.x));
+ if (core) |p| {
+ if (track) |active| {
+ const box = active.contentBox();
+ const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(pardes.BOX_H)) else box.y;
+ if (@as(f32, @floatFromInt(row)) >= tag_y and
+ @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(pardes.BOX_H)))
+ return compactTaglineLayout(g, box.x);
+ }
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = p.rects[id];
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
+ if (row == tag_y and col >= r.x and col < r.x + r.w)
+ return compactTaglineLayout(g, @floatFromInt(r.x));
+ }
}
- // A stale/closing cell without a live pane should still remain legible.
- // Its body-grid origin is the only safe fallback available.
+ // A stale/closing cell without a live pane should still remain legible,
+ // and so should every cell of an attached window. Its body-grid origin is
+ // the only safe fallback available.
return compactTaglineLayout(g, @floatFromInt(col));
}
@@ -1639,11 +1696,23 @@ fn setTransitionFields(
pub const run = runNative;
-fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
- const io = init.io;
+/// `attach` is `--attach[=<name>]`: empty means "the session there is" (see
+/// `detached_client.resolve`). It is a parameter rather than an `Options` field
+/// because it says nothing to the core — this process does not have one when it
+/// is set.
+fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u8) !void {
const gpa = init.gpa;
const env = init.environ_map;
- if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in);
+ // PARDES_TEST_GRID owns the process when it is set, and it is headless.
+ // There is no window to hand to a session, so refuse the combination
+ // rather than silently dropping the flag a harness meant.
+ if (env.get("PARDES_TEST_GRID") != null) {
+ if (attach != null) {
+ log.err("--attach needs a window; PARDES_TEST_GRID is headless", .{});
+ return error.AttachNeedsWindow;
+ }
+ return runGrid(init, opts_in);
+ }
const test_mode = env.get("PARDES_TEST") != null;
const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR");
@@ -1865,7 +1934,40 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
_ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc);
g.pen_x = cell_w;
- // ---- the core ----
+ // `--attach`: this process has a window and NO core. Everything above is
+ // the window and the rasterizer, which an attached frontend needs exactly
+ // as much as a whole session does; everything below is the core, which
+ // lives in the detached process (src/detached/). The branch is here so both
+ // leave by the same door — the GPU objects, the glyph atlas and the face
+ // are put away by the defers above whichever mode ran.
+ if (attach) |requested| return attachRequested(gpa, &g, requested);
+ // ...and the same handover arrived at from the other side: the `Attach`
+ // builtin gives this window to a session mid-flight. `localSession` returns
+ // a CONNECTED client only, and by the time it does every pane shell, watch
+ // and mount of the local session is already away.
+ var attached: ?detached_client.Client = null;
+ try localSession(init, &g, opts_in, test_mode, &attached);
+ if (attached) |*client| return attachedLoop(gpa, &g, client);
+}
+
+/// The session that lives in THIS process: the core, its pane shells, its
+/// watches, its acme filesystem and the loop that pumps them. A function of its
+/// own rather than the tail of `runNative` because that makes its teardown a
+/// scope exit instead of a second copy of the same twelve defers — and the
+/// `Attach` builtin needs exactly that teardown, in exactly that LIFO order,
+/// before an attached loop may draw on the same window.
+///
+/// `attached` is how a connected client leaves: it is set only after a
+/// handshake is in flight, which is what makes a failed `Attach` a no-op.
+fn localSession(
+ init: std.process.Init,
+ g: *Gui,
+ opts_in: pardes.Options,
+ test_mode: bool,
+ attached: *?detached_client.Client,
+) !void {
+ const io = init.io;
+ const gpa = init.gpa;
const allocs = pardes.allocators.init(gpa);
defer pardes.allocators.deinit();
pardes.image.start(io, allocs.image); // stb_image allocator for image panes
@@ -1891,9 +1993,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var pw: c_int = 0;
var ph: c_int = 0;
if (opts.load_path != null) {
- _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
- opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
- opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
+ opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
}
var core = if (opts.load_path) |lp| blk: {
const bytes = try @import("../look.zig").readFile(gpa, lp);
@@ -1905,8 +2007,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// construction: argv image panes no longer freeze the startup capability
// into their PETSCII preference, so their first render emits attachments.
core.native_images = true;
- observeGuiFont(&g, core);
- syncTaglineFont(&g, core);
+ observeGuiFont(g, core);
+ syncTaglineFont(g, core);
// Private, complete before any fork and retained until the last possible
// spawn; children borrow only these stable in-struct path buffers.
@@ -1967,7 +2069,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var shell: Shell = .{
.core = core,
- .gui = &g,
+ .gui = g,
.io = io,
.gpa = gpa,
.lsp_allocator = allocs.lsp,
@@ -2003,17 +2105,46 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// it a control pipe that CAN wake it out of poll().
fs_service.wake(fs, &queue, wakeFs);
- _ = c.SDL_StartTextInput(window);
+ _ = c.SDL_StartTextInput(g.window);
if (test_mode) setStdinRaw() catch {};
shell.threads_ok = true;
- // The core owns the loop. This owns the one thing a pump cannot do from
- // inside itself: Restore swaps the whole Pardes, which is only safe
- // BETWEEN iterations.
+ // The core owns the loop. This owns the two things a pump cannot do from
+ // inside itself, because both replace the whole session and are only safe
+ // BETWEEN iterations: Restore swaps the `Pardes`, and Attach retires it.
while (!core.quit) {
try core.pump(host);
if (core.quit) break; // a session that ended does not restore into one
+ // Attach builtin: hand this window's screen to a detached session.
+ //
+ // GREET FIRST, SWAP SECOND, and that order IS the feature.
+ // `detached_client.attempt` resolves, connects AND waits for the
+ // `welcome`, and closes whatever it opened on every other outcome — so
+ // when this returns anything but `.greeted`, nothing below has run and
+ // this instance is exactly as it was: every pane, every shell, every
+ // unsaved buffer, the whole undo history. It says why on the row of the
+ // pane that ran the word and the session goes on. A half-torn-down
+ // editor is the one outcome an attach must never have, and `open` alone
+ // cannot rule it out — a `refuse .version` from a session built by the
+ // last `zig build` arrives AFTER the connect.
+ if (core.takeAttach()) |req| {
+ const geom = windowCells(g);
+ const outcome = detached_client.attempt(gpa, req.name, geom.cols, geom.rows);
+ switch (outcome) {
+ .greeted => |client| {
+ // Greeted, so this session is over: the `break` runs the
+ // filesystem unmount below and then every defer above, and
+ // `runNative` picks the client up on the far side.
+ attached.* = client;
+ break;
+ },
+ else => {
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(req.pane, attachFailure(&mbuf, outcome, req.name));
+ },
+ }
+ }
// Restore builtin: swap in a core rebuilt from the dump; kill the live
// shells (their detached readers wake on child death; gens bumped so
// the stale eofs close the old fds without touching the replay panes)
@@ -2041,7 +2172,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.{ .text = 0 },
);
_ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false);
- clearNativeImages(&g);
+ clearNativeImages(g);
g.presented_images.clearRetainingCapacity();
g.prepared_images.clearRetainingCapacity();
nc.native_images = true;
@@ -2050,8 +2181,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
core = nc;
shell.core = nc;
shell.surface = null;
- observeGuiFont(&g, core);
- syncTaglineFont(&g, core);
+ observeGuiFont(g, core);
+ syncTaglineFont(g, core);
}
}
@@ -2066,6 +2197,243 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
}
}
+// =====================================================================
+// --attach: a window, a socket, and no core
+// =====================================================================
+
+/// What to say when an attach did not happen. One function for both callers
+/// because it is one set of outcomes: `--attach` logs it to a terminal it has
+/// not drawn over yet, the `Attach` word puts it on the pane's message row, and
+/// neither should be inventing its own wording for `refuse .version`.
+///
+/// `requested` is the word a person typed, empty for "the session that is
+/// there" — which is the whole difference between "no session called work" and
+/// "nothing is detached".
+fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const u8) []const u8 {
+ return switch (outcome) {
+ // The caller took this one and never asks.
+ .greeted => unreachable,
+ .no_session => if (requested.len != 0)
+ std.fmt.bufPrint(buf, "Attach: no detached session called '{s}'", .{requested}) catch
+ "Attach: no detached session under that name"
+ else
+ "Attach: nothing is detached (start one with `pardes --detach`)",
+ .ambiguous => |n| std.fmt.bufPrint(buf, "Attach: {d} detached sessions; say which one", .{n}) catch
+ "Attach: several detached sessions; say which one",
+ .refused => |why| switch (why) {
+ .version => "Attach: that session speaks a different wire version — it is another build of pardes",
+ .full => "Attach: that session already has every frontend slot taken",
+ .quitting => "Attach: that session is ending",
+ },
+ .silent => "Attach: that session took the connection and never greeted us",
+ .lost => |err| std.fmt.bufPrint(buf, "Attach: lost the connection ({t})", .{err}) catch
+ "Attach: lost the connection",
+ };
+}
+
+/// `--attach[=<name>]`: this window is a frontend from its first frame. Split
+/// from `attachedLoop` because the two arrive with different evidence — a
+/// command line has a person at a terminal to tell when there is nothing to
+/// attach to and a process exit status to carry it, while an `Attach` inside a
+/// session has a pane's message row and a live editor to leave standing. Both
+/// reach `attachedLoop` with a GREETED client and never with less.
+fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void {
+ const geom = windowCells(g);
+ const outcome = detached_client.attempt(gpa, requested, geom.cols, geom.rows);
+ switch (outcome) {
+ .greeted => |greeted| {
+ var client = greeted;
+ return attachedLoop(gpa, g, &client);
+ },
+ else => {
+ // The window exists but has drawn nothing, so stderr is still the
+ // only place a person is looking; the wording is the message row's,
+ // because it is the same set of outcomes.
+ var mbuf: [256]u8 = undefined;
+ log.err("{s}", .{attachFailure(&mbuf, outcome, requested)});
+ // ...and the exit status keeps the distinction the sentence makes,
+ // for whatever launched this window.
+ return switch (outcome) {
+ .no_session => error.NoSession,
+ .ambiguous => error.AmbiguousSession,
+ .refused => error.Refused,
+ .silent => error.NoGreeting,
+ .lost => |err| err,
+ .greeted => unreachable,
+ };
+ },
+ }
+}
+
+/// The whole of an attached window: input and screen, and nothing else. SDL
+/// events become `pardes.Event`s on the socket through the same `dispatch` a
+/// local session uses; frames come back and go through the same `renderFrame`.
+/// It forks no shell, writes no file and watches no path — the session process
+/// does all of that now — so the only effects still arriving here are the three
+/// that need a human's own display.
+fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void {
+ // The `bye` is a courtesy: the session survives a frontend that simply
+ // dies, but seven bytes turn "the peer vanished" into "the peer left" in
+ // its log.
+ defer client.detach();
+ // The window may have arrived here from `localSession`, where this was
+ // already called; SDL_StartTextInput is idempotent, and calling it is what
+ // makes the `--attach`-from-startup path receive SDL_EVENT_TEXT_INPUT at
+ // all.
+ _ = c.SDL_StartTextInput(g.window);
+
+ var in: Input = .{ .client = client };
+ // A frame is the only thing that makes this window redraw. There is no
+ // animation clock and no core asking for a tick — the session spends both
+ // and sends the result — so a pass that saw nothing new presents nothing.
+ var dirty = false;
+ var geom = windowCells(g);
+ while (true) {
+ const link = client.wait(detached_client.poll_ms);
+ // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in the
+ // same call that read the last bytes, and the last bytes are the
+ // session's `quit`: `fill` appends every chunk and only then sees the
+ // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly
+ // this reason, and honouring it is what makes an ordinary `Kill` close
+ // every attached window by the front door instead of leaving whichever
+ // one lost the race reporting a broken link.
+ while (true) {
+ const msg = (try client.next()) orelse break;
+ switch (msg) {
+ // A greeting cannot arrive twice and a refusal cannot follow
+ // one at all — `detached_client.attempt` consumed the welcome
+ // before this loop was entered, and the union is exhaustive, so
+ // these two arms exist to say that rather than to do anything.
+ // A session that sent either here is not speaking this protocol.
+ .welcome => {},
+ .refuse => |why| {
+ log.err("session refused an already-greeted frontend: {t}", .{why});
+ return error.Refused;
+ },
+ // Applied too — `grid` and `cursor` are current by the time
+ // this lands, so all that is left is putting them on screen.
+ .frame => dirty = true,
+ // THE SESSION ENDED (`Kill`): every frontend goes with it.
+ .quit => return,
+ // ...and `Detach`: THIS frontend was asked to leave and the
+ // session is carrying on without it, panes and shells and undo
+ // history intact, with whatever other frontends are attached
+ // still looking at it. Leaving because a person asked is a
+ // SUCCESS — hence a plain return and not the `error.Refused`
+ // above — and the deferred `client.detach()` still sends the
+ // `bye`, so the session logs a peer that left rather than one
+ // that vanished. The window closes because `runNative` returns.
+ .detach => return,
+ .set_clipboard => |text| putClipboard(gpa, text),
+ // The answer is not a reply message: it is an ordinary paste
+ // event on the way back, which is the same asynchronous shape
+ // `pull_read_clipboard` already has in process.
+ .read_clipboard => if (takeClipboard()) |text| {
+ defer c.SDL_free(text.ptr);
+ in.post(.{ .paste = text });
+ },
+ .open_link => |url| look.openLink(url),
+ }
+ }
+ try link;
+
+ var sev = std.mem.zeroes(c.SDL_Event);
+ while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev);
+ pollGamepad(g, &in);
+ // One check for the whole burst rather than one per event: `Input.post`
+ // stops sending at the first failure, so this is where a dead link is
+ // reported and there is nothing left in flight to lose.
+ if (in.lost) |err| return err;
+ if (in.quit) return;
+
+ // What this WINDOW can show, which is not a promise about the next
+ // frame: with several frontends attached the session grid is the
+ // smallest common one (client.zig GEOMETRY).
+ const now = windowCells(g);
+ if (now.cols != geom.cols or now.rows != geom.rows) {
+ geom = now;
+ try client.resize(now.cols, now.rows);
+ }
+ if (!dirty) continue;
+ dirty = false;
+ paintAttached(g, gpa, client);
+ }
+}
+
+/// The frame the session sent, through the renderer this window already has.
+/// The `Surface` is built OVER the client's grid rather than copied into one:
+/// `renderFrame` reads cells and never writes them, and a full frame of a large
+/// grid is 1.6 MiB.
+///
+/// Three of a session's own surface fields are absent here and each absence is
+/// load-bearing. No panel tracks: a pane transition is composed by the process
+/// that owns the panes and what arrives is the composed result, so `makePaintPlan`
+/// builds its single static batch. No pixel attachments: this wire carries no
+/// images. No previous cells: `hasPanelDiff` is therefore false and the whole
+/// old/new layer machinery stays out of the plan.
+fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void {
+ var surface: pardes.Surface = .{
+ .cols = client.cols,
+ .rows = client.rows,
+ .cells = client.grid.items,
+ .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null,
+ };
+ // Two of `renderFrame`'s arguments are chrome colours the session resolved
+ // off a theme that is not on the wire. Both want `chromeTheme().tag_bg`,
+ // and the frame carries it exactly: row zero IS a full-width band that
+ // pardes.zig fills with that colour unconditionally, which is what
+ // `frameChromeBg` reads. The topbar rule then wears the band's own colour,
+ // joining the two bands directly the way
+ // `config.gui_topbar_pane_border_px = 0` does — a rule whose colour we
+ // would have to invent is worse than no rule.
+ const chrome = frameChromeBg(&surface);
+ _ = renderFrame(
+ g,
+ gpa,
+ null,
+ &surface,
+ // No theme background either, so every cell the session left at its
+ // default wears this window's own ground — the same answer a terminal
+ // frontend gives by writing a default cell.
+ null,
+ chrome,
+ chrome,
+ // Crt/Ripple/Glitch are core settings and the core is elsewhere; so is
+ // Debug, which is what the touch overlay hangs off.
+ .{},
+ false,
+ ) catch |err| blk: {
+ log.err("render: {t}", .{err});
+ break :blk false;
+ };
+}
+
+/// The tagline background this frame was painted with, read off the frame. An
+/// attached window has no core to ask for `chromeTheme().tag_bg`, and
+/// `renderFrame` wants it twice: as the chrome band under the topbar's compact
+/// cells, and as the sub-cell strip `buildOverlay` extends below a bottom
+/// tagline band when the window is not a whole number of cells tall.
+///
+/// ROW ZERO is where it is read, and that is not a guess: the topbar is filled
+/// edge to edge with `chrome.tag_bg` at `font_role = .tagline` on every frame
+/// (pardes.zig `renderTopbar`), so its first tagline cell IS the colour. The
+/// last row was the wrong place to look and cost a visibly dark band — a
+/// session whose bottom row is pane BODY has no tagline cell there at all, so
+/// the scan fell through to `bg_default` and painted the topbar's remainder
+/// and every tag-cell gap near-black.
+fn frameChromeBg(surface: *const pardes.Surface) [3]u8 {
+ if (surface.rows == 0 or surface.cols == 0) return bg_default;
+ for (surface.cells[0..surface.cols]) |cell| {
+ if (cell.default or cell.style.font_role != .tagline) continue;
+ return switch (cell.style.bg) {
+ .default => bg_default,
+ .index => |i| palColor(i),
+ .rgb => |rgb| rgb,
+ };
+ }
+ return bg_default;
+}
+
/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in,
/// the rendered Surface out as text frames (same framing as the prototype).
fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
@@ -2183,7 +2551,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
// The two halves of a pass's input, in the order the flat loop had
// them: the scripted feed, then whatever the reader threads handed
// over. `pump` has no `wait_input` to do it in — see `grid_vtable`.
- const r = try shell.feed.pump(gpa, core, null);
+ var in: Input = .{ .core = core };
+ const r = try shell.feed.pump(gpa, &in, null);
if (r.eof) break;
if (r.n_events != 0) shell.saw_event = true;
shell.drainQueue();
@@ -2222,7 +2591,7 @@ const StdinFeed = struct {
const Result = struct { eof: bool = false, n_events: usize = 0 };
/// Poll stdin briefly and translate what arrived. `g` is null in grid mode.
- fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result {
+ fn pump(f: *StdinFeed, gpa: std.mem.Allocator, in: *Input, g: ?*Gui) !Result {
var out: Result = .{};
// a pty stdin also carries the winsize; poll it in place of SIGWINCH
var ws: posix.winsize = std.mem.zeroes(posix.winsize);
@@ -2232,7 +2601,7 @@ const StdinFeed = struct {
{
f.last_cols = ws.col;
f.last_rows = ws.row;
- f.applyResize(core, g, ws.col, ws.row);
+ f.applyResize(in, g, ws.col, ws.row);
out.n_events += 1;
}
@@ -2273,14 +2642,14 @@ const StdinFeed = struct {
if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
finger.x = m.x / win_w;
finger.y = m.y / win_h;
- handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
+ handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
} else {
finger.kind = .cancel;
- handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
- core.update(.pointer_leave);
+ handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
+ in.post(.pointer_leave);
}
- } else {
- handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1);
+ } else if (in.core) |core| {
+ handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1);
}
out.n_events += 1;
}
@@ -2305,7 +2674,7 @@ const StdinFeed = struct {
.key_press => |key| {
var text: []const u8 = "";
if (key.text) |t| text = f.cache.put(t);
- core.update(.{ .key = .{
+ in.post(.{ .key = .{
.cp = mapKey(effCp(key)),
.text = text,
.ctrl = key.mods.ctrl,
@@ -2327,7 +2696,7 @@ const StdinFeed = struct {
else => null,
};
if (button) |b| {
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = b,
.kind = switch (m.type) {
.press => .press,
@@ -2343,11 +2712,11 @@ const StdinFeed = struct {
}
},
.winsize => |vw| {
- f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows));
+ f.applyResize(in, g, @intCast(vw.cols), @intCast(vw.rows));
out.n_events += 1;
},
.paste => |text| {
- core.update(.{ .paste = text });
+ in.post(.{ .paste = text });
gpa.free(@constCast(text));
out.n_events += 1;
},
@@ -2357,14 +2726,14 @@ const StdinFeed = struct {
return out;
}
- fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void {
+ fn applyResize(f: *StdinFeed, in: *Input, g: ?*Gui, cols: u16, rows: u16) void {
_ = f;
if (g) |gp| {
// capture mode: resize the window; the frame loop resizes the core
_ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h));
_ = c.SDL_SyncWindow(gp.window);
} else {
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ in.post(.{ .resize = .{ .cols = cols, .rows = rows } });
}
}
};
@@ -2459,20 +2828,71 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
@memcpy(frame[at..][0..footer.len], footer);
at += footer.len;
std.debug.assert(at == frame.len);
- writeFd(1, frame);
+ host_io.writeFd(1, frame);
}
// =====================================================================
// SDL event dispatch
// =====================================================================
-fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
+/// Where a translated SDL event goes, and the only thing the input path knows
+/// about the session it belongs to. The local shell hands events to the
+/// `Pardes` in this process; an attached window puts them on a socket, because
+/// the core is in the detached one. Everything between an SDL_Event and a
+/// `pardes.Event` — the keycode table, the pointer/cell mapping, the touch
+/// machine, the Steam Deck mapping — is ONE translation serving both, and this
+/// is what keeps it from becoming two.
+const Input = struct {
+ /// Null in an attached window, and this is also the flag the renderer- and
+ /// pointer-side functions test: no core means no pane rects and no theme,
+ /// and each of those has a documented body-grid fallback.
+ core: ?*pardes.Pardes = null,
+ /// Null in a local session. Exactly one of the two is ever set.
+ client: ?*detached_client.Client = null,
+ /// Attached only: the window was closed. A local session says the same
+ /// thing by writing `core.quit`, which the core owns and this must not
+ /// shadow.
+ quit: bool = false,
+ /// Attached only: a send failed, which means this window has lost its
+ /// session. Recorded rather than returned because `dispatch` is called from
+ /// inside an SDL drain with no error path, and a dead link does not need
+ /// reporting once per event in the burst.
+ lost: ?anyerror = null,
+
+ /// One translated event on its way to the core, wherever the core is.
+ fn post(in: *Input, ev: pardes.Event) void {
+ if (in.core) |core| return core.update(ev);
+ const client = in.client orelse return;
+ // Nothing more goes out after the first failure: the rest of this
+ // burst would each fail the same way, and the loop is about to leave.
+ if (in.lost != null) return;
+ client.send(.{ .event = ev }) catch |err| switch (err) {
+ // A message this protocol cannot carry is not a link that has
+ // died. The one event here that can reach `wire.max_payload` is a
+ // paste of a 16 MiB clipboard, and dropping it beats ending a
+ // session over it.
+ error.Overlong, error.NoSpace => {},
+ else => in.lost = err,
+ };
+ }
+
+ /// The window asked to close. In a session that ends the session; in an
+ /// attached window it ends this frontend and nothing else — the panes, the
+ /// shells and the undo history are in the other process and outlive it,
+ /// which is the whole point of `--detach`.
+ fn close(in: *Input) void {
+ if (in.core) |core| core.quit = true;
+ in.quit = true;
+ }
+};
+
+fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
switch (sev.type) {
- c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true,
+ c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(),
c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
g.pointer_present = false;
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
},
// window resizes are picked up by the per-frame grid check
c.SDL_EVENT_KEY_DOWN => {
@@ -2480,8 +2900,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0;
// Ctrl+ / Ctrl-: the font size. Here rather than in keyDown
// because it is the shell's business and not the core's — the
- // core has no font — and because this is the only side of the
- // wall where `g` is in scope anyway.
+ // core has no font, and an attached window has no core at all yet
+ // still resizes its own text — and because this is the only side
+ // of the wall where `g` is in scope anyway.
//
// SIX keycodes for two keys, and every one of them is a key
// somebody actually presses:
@@ -2520,12 +2941,15 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
// the size already on screen
if (want != g.px) {
g.px = want;
- refitFont(g, core);
- observeGuiFont(g, core);
+ refitFont(g, in.core);
+ // Attached, the new grid reaches the session as the
+ // ordinary window-geometry check on the next pass, and
+ // there is no local Font state to observe either.
+ if (in.core) |core| observeGuiFont(g, core);
}
return;
}
- keyDown(core, sev.key.key, sev.key.mod);
+ keyDown(in, sev.key.key, sev.key.mod);
},
c.SDL_EVENT_KEY_UP => {
g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0;
@@ -2548,7 +2972,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var it = view.iterator();
var at: usize = 0;
while (it.nextCodepoint()) |cp| : (at = it.i) {
- core.update(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } });
+ in.post(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } });
}
},
c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
@@ -2562,22 +2986,22 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.pad_x = b.x;
g.pad_y = b.y;
g.pointer_present = true;
- const mc = mouseCell(g, core, b.x, b.y) orelse {
+ const mc = mouseCell(g, in.core, b.x, b.y) orelse {
g.pointer_mapped = false;
// A release outside the visible CRT tube still ends a drag at
// its last real cell; a press on black margin is inert.
- if (!b.down) if (g.pointer_cell) |last| core.update(.{ .mouse = .{
+ if (!b.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{
.button = button,
.kind = .release,
.col = last.col,
.row = last.row,
} });
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
return;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- core.update(.{
+ in.post(.{
.mouse = .{
.button = button,
.kind = if (b.down) .press else .release,
@@ -2605,14 +3029,14 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
.right
else
null;
- const mc = mouseCell(g, core, m.x, m.y) orelse {
+ const mc = mouseCell(g, in.core, m.x, m.y) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
return;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
.col = mc.col,
@@ -2625,53 +3049,68 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.pad_x = w.mouse_x;
g.pad_y = w.mouse_y;
g.pointer_present = true;
- const mc = mouseCell(g, core, w.mouse_x, w.mouse_y) orelse {
+ const mc = mouseCell(g, in.core, w.mouse_x, w.mouse_y) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
return;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
if (w.y != 0 and std.math.isFinite(w.y)) {
- // Preserve SDL's floating-point distance. Input events drained
- // in this loop naturally form one render batch; stepScroll
- // applies their exact sum and tells the core only about whole
- // row boundaries. One accumulator belongs to one pane, so a
- // wheel event over another pane first retires the old offset.
- const hit: ?usize = for (core.panes, 0..) |slot, i| {
- if (slot == null) continue;
- const r = core.rects[i];
- if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i;
- } else null;
- if (g.scroll_pane) |old| if (hit == null or hit.? != old) {
- // There is one fractional overlay, not one per pane. Retire
- // the old one at its already boundary-rounded core state;
- // carrying its lag into `hit` would move the wrong pane.
- resetScroll(g);
- };
- if (hit) |id| {
- const pdf_target = if (comptime pardes.pdf_enabled)
- core.native_images and core.panes[id].?.pdfPage() != null
- else
- false;
- if (pdf_target) {
- // PDF placements live in physical document space, so
- // preserve SDL's raw magnitude directly instead of
- // quantizing through synthetic wheel buttons/rows.
+ if (in.core) |core| {
+ // Preserve SDL's floating-point distance. Input events drained
+ // in this loop naturally form one render batch; stepScroll
+ // applies their exact sum and tells the core only about whole
+ // row boundaries. One accumulator belongs to one pane, so a
+ // wheel event over another pane first retires the old offset.
+ const hit: ?usize = for (core.panes, 0..) |slot, i| {
+ if (slot == null) continue;
+ const r = core.rects[i];
+ if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i;
+ } else null;
+ if (g.scroll_pane) |old| if (hit == null or hit.? != old) {
+ // There is one fractional overlay, not one per pane. Retire
+ // the old one at its already boundary-rounded core state;
+ // carrying its lag into `hit` would move the wrong pane.
resetScroll(g);
- core.update(.{ .pdf_scroll = .{
- .pane = @intCast(id),
- .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
- } });
- } else {
- g.scroll_pane = id;
- g.scroll_col = mc.col;
- g.scroll_row = mc.row;
- g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ };
+ if (hit) |id| {
+ const pdf_target = if (comptime pardes.pdf_enabled)
+ core.native_images and core.panes[id].?.pdfPage() != null
+ else
+ false;
+ if (pdf_target) {
+ // PDF placements live in physical document space, so
+ // preserve SDL's raw magnitude directly instead of
+ // quantizing through synthetic wheel buttons/rows.
+ resetScroll(g);
+ in.post(.{ .pdf_scroll = .{
+ .pane = @intCast(id),
+ .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
+ } });
+ } else {
+ g.scroll_pane = id;
+ g.scroll_col = mc.col;
+ g.scroll_row = mc.row;
+ g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ }
}
+ } else {
+ // ATTACHED: no pane rect ever reaches this window, so there
+ // is nothing to slide a fractional row against — the
+ // session owns the panes and composes what is painted here.
+ // Accumulate SDL's exact distance (a precision touchpad
+ // sends fractions of a row) in the same field `stepScroll`
+ // would have drained, and hand the session the whole rows,
+ // which is all `Event.mouse` has ever been able to say.
+ g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ while (g.scroll_delta >= 1) : (g.scroll_delta -= 1)
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row } });
+ while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row } });
}
}
- if (w.x != 0) core.update(.{ .mouse = .{
+ if (w.x != 0) in.post(.{ .mouse = .{
.button = if (w.x > 0) .wheel_right else .wheel_left,
.kind = .press,
.col = mc.col,
@@ -2702,14 +3141,14 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
finger.x = m.x / win_w;
finger.y = m.y / win_h;
- handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
+ handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
} else {
finger.kind = .cancel;
- handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
- core.update(.pointer_leave);
+ handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
+ in.post(.pointer_leave);
}
},
- c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }),
+ c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }),
// ---- steamdeck: first gamepad drives a virtual mouse (buttons here,
// axes polled per frame in pollGamepad) ----
c.SDL_EVENT_GAMEPAD_ADDED => {
@@ -2730,7 +3169,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION,
c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP,
=> {
- const in: deck.Input = switch (sev.type) {
+ // `pad_input` and not `in`: this file's `Input` is the event sink
+ // above, and deck.Input is a controller reading.
+ const pad_input: deck.Input = switch (sev.type) {
c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{
.idx = sev.gbutton.button,
.down = sev.gbutton.down,
@@ -2752,10 +3193,10 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
} },
};
var acts: [3]deck.Action = undefined;
- for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) {
+ for (acts[0..g.deck.feed(pad_input, &acts)]) |act| switch (act) {
.move => |mv| {
const geometry = windowGeometry(g.window);
- const old = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry);
+ const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry);
// deck.Action.move is in physical screen pixels. Keep the
// stored/warped cursor in SDL window coordinates.
const dx = mv.dx * geometry.window_w / geometry.pixel_w;
@@ -2766,9 +3207,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
// cursor and a hardware mouse are one visible pointer
c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
g.pointer_present = true;
- const mc = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry) orelse {
+ const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
continue;
};
g.pointer_mapped = true;
@@ -2778,7 +3219,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
// moving with a click held drags, so selections stretch
// (a firm right-pad press drags-selects like a laptop pad)
const held = heldPointerButton(g);
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
.col = mc.col,
@@ -2792,23 +3233,23 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
.look => .right,
};
g.pointer_present = true;
- const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse {
+ const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse {
g.pointer_mapped = false;
// Mirror hardware mouse releases: black CRT margins
// are inert for presses, but cannot strand a drag whose
// button was pressed over the visible tube.
- if (!ck.down) if (g.pointer_cell) |last| core.update(.{ .mouse = .{
+ if (!ck.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{
.button = button,
.kind = .release,
.col = last.col,
.row = last.row,
} });
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
continue;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = button,
.kind = if (ck.down) .press else .release,
.col = mc.col,
@@ -2817,9 +3258,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
},
.wheel => |ticks| {
g.pointer_present = true;
- const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse {
+ const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
continue;
};
g.pointer_mapped = true;
@@ -2827,7 +3268,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var left = ticks;
while (left != 0) {
left += if (ticks > 0) -1 else 1;
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = if (ticks > 0) .wheel_down else .wheel_up,
.kind = .press,
.col = mc.col,
@@ -2835,14 +3276,23 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
} });
}
},
- .key => |k| core.update(.{
+ .key => |k| in.post(.{
.key = switch (k) {
.n => .{ .cp = 'n', .text = "n" },
.cap_n => .{ .cp = 'N', .text = "N" },
.enter => .{ .cp = pardes.Key.enter },
.tab => .{ .cp = pardes.Key.tab },
- // back paddle: Ctrl-<configured toggle key> flips tty mode
- .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true },
+ // back paddle: flip tty mode. `--tty-toggle` moves the
+ // ctrl chord and is the SESSION's option, so an
+ // attached window — which cannot know it and has no
+ // core to ask — sends the spelling that cannot be
+ // reconfigured instead: `config.tty_toggle_alt`, which
+ // pardes.zig honours beside the ctrl chord for exactly
+ // the hosts that can express it.
+ .tty_toggle => if (in.core) |core|
+ .{ .cp = core.opts.tty_toggle, .ctrl = true }
+ else
+ .{ .cp = config.tty_toggle_alt[0].cp, .shift = true },
},
}),
// a brief gentle ack for execute/look, not a buzz
@@ -2857,7 +3307,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as
/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly).
-fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
+fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
const ctrl = (mod & c.SDL_KMOD_CTRL) != 0;
const alt = (mod & c.SDL_KMOD_ALT) != 0;
const shift = (mod & c.SDL_KMOD_SHIFT) != 0;
@@ -2885,7 +3335,7 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
},
};
if (cp == 0) return;
- core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } });
+ in.post(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } });
}
fn pixelCell(px: f32, cell: u32) u16 {
@@ -2893,8 +3343,13 @@ fn pixelCell(px: f32, cell: u32) u16 {
return @intFromFloat(@min(idx, 10_000));
}
+/// Which grid cell a physical point is in. `core` is null in an attached
+/// window, and then the pane loop is skipped and the body grid answers — the
+/// same fallback `taglineLayoutForCell` takes for the same missing fact, which
+/// is what makes a click on an attached tagline land on the glyph it was aimed
+/// at.
fn gridCellAtDimensions(
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
x: f32,
y: f32,
body_w: f32,
@@ -2908,21 +3363,21 @@ fn gridCellAtDimensions(
if (row < pardes.TOPBAR_H)
return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / tag_w), 10_000)), .row = row };
- for (core.panes, 0..) |slot, id| {
+ if (core) |p| for (p.panes, 0..) |slot, id| {
if (slot == null) continue;
- const r = core.rects[id];
- const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
+ const r = p.rects[id];
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
if (row != tag_y or r.w == 0) continue;
const left = @as(f32, @floatFromInt(r.x)) * safe_body_w;
const right = @as(f32, @floatFromInt(r.x + r.w)) * safe_body_w;
if (x < left or x >= right) continue;
const within: u16 = @intFromFloat(@min(@floor(@max(0, x - left) / tag_w), @as(f32, @floatFromInt(r.w - 1))));
return .{ .col = r.x + within, .row = row };
- }
+ };
return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / safe_body_w), 10_000)), .row = row };
}
-fn gridCellAtPixels(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) MouseCell {
+fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell {
return gridCellAtDimensions(
core,
x,
@@ -2961,7 +3416,7 @@ fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point {
return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds);
}
-fn mouseCellWithGeometry(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
+fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null;
return gridCellAtPixels(g, core, mapped.x, mapped.y);
@@ -2969,7 +3424,7 @@ fn mouseCellWithGeometry(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f
/// SDL window coords → the scene cell displayed at that physical point.
/// Mouse, touch and the Deck pointer all share the same CRT/ripple/glitch map.
-fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) ?MouseCell {
+fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell {
return mouseCellWithGeometry(g, core, x, y, windowGeometry(g.window));
}
@@ -3009,14 +3464,14 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void {
/// RIGHT stick moves the virtual cursor at ~cell granularity (emits
/// button-less motion so hover works), LEFT stick accumulates into wheel
/// ticks (both axes: vertical + horizontal) at the cursor position.
-fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
+fn pollGamepad(g: *Gui, in: *Input) void {
const pad = g.gamepad orelse return;
const geometry = windowGeometry(g.window);
const deadzone: f32 = 8000;
// right stick = pointer
const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX));
const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
- const old = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry);
+ const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry);
// full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the
// mouse-move sensitivity knob — raise for a faster pointer)
const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4;
@@ -3031,8 +3486,8 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
g.pointer_present = true;
c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
}
- const mc = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry) orelse {
- if (g.pointer_present and g.pointer_mapped) core.update(.pointer_leave);
+ const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse {
+ if (g.pointer_present and g.pointer_mapped) in.post(.pointer_leave);
if (g.pointer_present) g.pointer_mapped = false;
return;
};
@@ -3041,7 +3496,7 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
g.pointer_cell = mc;
if (old == null or mc.col != old.?.col or mc.row != old.?.row) {
const held = heldPointerButton(g);
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
.col = mc.col,
@@ -3057,12 +3512,12 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
while (@abs(g.pad_scroll) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down;
g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0;
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
}
while (@abs(g.pad_scroll_h) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right;
g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0;
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
}
}
@@ -3237,19 +3692,23 @@ fn shellOf(ctx: ?*anyopaque) *Shell {
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = shellOf(ctx);
const core = s.core;
+ // The one place a local session builds the sink: everything downstream of
+ // here — `dispatch`, the scripted feed, the sticks — is the same code an
+ // attached window runs with `client` set instead.
+ var in: Input = .{ .core = core };
if (s.gui) |g| {
var sev = std.mem.zeroes(c.SDL_Event);
const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16;
if (c.SDL_WaitEventTimeout(&sev, ms)) {
- dispatch(g, core, &sev);
- while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev);
+ dispatch(g, &in, &sev);
+ while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev);
}
}
if (s.test_mode) {
// A dead scripted feed ends the session HERE, before the inbox, the
// sticks and the frame: the pre-pump loop broke at this line, and a
// capture written after EOF is a frame no script asked for.
- const r = s.feed.pump(s.gpa, core, s.gui) catch {
+ const r = s.feed.pump(s.gpa, &in, s.gui) catch {
core.quit = true;
return;
};
@@ -3259,7 +3718,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
}
}
s.drainQueue();
- if (s.gui) |g| pollGamepad(g, core);
+ if (s.gui) |g| pollGamepad(g, &in);
}
/// Per-frame host bookkeeping with no event of its own, in the order the flat
@@ -3307,12 +3766,8 @@ fn pollFrame(ctx: ?*anyopaque) void {
pollCwds(core, s.ptys);
// Off g.cell_w/h, not the startup metrics: a font change moves them, and
// this is the line that would go on dividing by the old cell.
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g);
+ const geom = windowCells(g);
+ if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h)) resetScroll(g);
stepScroll(g, core, s.gpa);
}
@@ -3435,7 +3890,12 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const pt = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
+ // The machine-local half is host_io.zig's, not this file's: the same fork
+ // the tty shell and the detached daemon do, including the CLOEXEC on the
+ // master that this copy used to be missing (a master a later shell inherits
+ // is never closed, so a deleted pane's shell never hangs up).
+ const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
+ const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid };
s.ptys[pane] = pt;
// report the pane's starting directory back to the core (tags); the slot
// needs no occupancy reset, nothing about it is remembered
@@ -3446,7 +3906,7 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
const s = shellOf(ctx);
- if (s.ptys[pane]) |pt| writeFd(pt.fd, bytes);
+ if (s.ptys[pane]) |pt| host_io.writeFd(pt.fd, bytes);
}
fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
@@ -3467,21 +3927,9 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
return look.ttyTaken(pt.pid, pt.fd);
}
-fn writeWholeFile(path: []const u8, bytes: []const u8) bool {
- var pathbuf: [4096:0]u8 = undefined;
- if (path.len >= pathbuf.len) return false;
- @memcpy(pathbuf[0..path.len], path);
- pathbuf[path.len] = 0;
- const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return false;
- writeFd(fd, bytes);
- _ = libc.close(fd);
- return true;
-}
-
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = shellOf(ctx);
- if (!writeWholeFile(path, bytes)) return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
// our own write is about to come back as a watch event: restamp from the
// bytes we just put there so it reads as "no change". Only for the pane's
// OWN file — a `Put` elsewhere is a change like any other.
@@ -3502,7 +3950,7 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = shellOf(ctx);
var pbuf: [1024:0]u8 = undefined;
const path = pardes.dump.outPath(&pbuf) orelse return;
- if (!writeWholeFile(path, bytes)) return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
s.core.setLastDump(path);
}
@@ -3538,12 +3986,35 @@ fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
}
+/// Put `text` on THIS display's clipboard. The one place that copy happens:
+/// SDL wants a sentinel-terminated string and a run of core cells is not one.
+/// Shared, because the in-process host and an attached window answering a
+/// `set_clipboard` off the wire are the same desktop action.
+fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void {
+ const z = gpa.dupeZ(u8, text) catch return;
+ defer gpa.free(z);
+ _ = c.SDL_SetClipboardText(z.ptr);
+}
+
+/// THIS display's clipboard, or null when it holds nothing. SDL3 hands over an
+/// OWNED copy that is the caller's to `SDL_free`, and reports "no text" as an
+/// EMPTY string rather than null — so the length check is what actually
+/// rejects a miss. Shared with the attached loop for the `putClipboard`
+/// reason, turned round.
+fn takeClipboard() ?[:0]u8 {
+ const raw = c.SDL_GetClipboardText() orelse return null;
+ const text = std.mem.span(raw);
+ if (text.len == 0) {
+ c.SDL_free(raw);
+ return null;
+ }
+ return text;
+}
+
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
const s = shellOf(ctx);
if (s.gui == null) return;
- const z = s.gpa.dupeZ(u8, text) catch return;
- defer s.gpa.free(z);
- _ = c.SDL_SetClipboardText(z.ptr);
+ putClipboard(s.gpa, text);
}
fn readClipboard(ctx: ?*anyopaque) void {
@@ -3551,13 +4022,8 @@ fn readClipboard(ctx: ?*anyopaque) void {
if (s.gui == null) return;
// SDL answers synchronously, so the paste the core is waiting on lands
// inside this same drain — nothing to remember, no reply path to plumb.
- // SDL3 hands over an OWNED copy that is ours to SDL_free, and reports "no
- // text" as an EMPTY string rather than null, so the length check is what
- // actually rejects a miss.
- const raw = c.SDL_GetClipboardText() orelse return;
- defer c.SDL_free(raw);
- const text = std.mem.span(raw);
- if (text.len == 0) return;
+ const text = takeClipboard() orelse return;
+ defer c.SDL_free(text.ptr);
s.core.update(.{ .paste = text });
}
@@ -3576,27 +4042,6 @@ fn pipe(ctx: ?*anyopaque, id: u32) void {
if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id);
}
-fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) Pty {
- var master: c_int = undefined;
- // resolved BEFORE the fork, into this frame, which the child inherits:
- // nothing between fork and exec may allocate, and a PATH search would
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
- // ...and so is the pane's own address on the control filesystem: acme puts
- // `winid` in the child, which is safe there only because rfork(RFENVG) has
- // just given it a private environment group. See fs_service.exportPaneEnv.
- fs_service.exportPaneEnv(fs, if (core.panes[pane]) |pn| pn.serial else 0);
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- if (cwd) |cd| _ = chdir(cd);
- _ = execv(spawn.path, &spawn.argv);
- _exit(127);
- }
- if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
- return .{ .fd = master, .pid = pid };
-}
-
/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame
/// because a tagline draws it. Whether a pane's tty still belongs to the prompt
/// pardes forked is deliberately NOT polled with it — see `ttyTaken`.
@@ -3817,7 +4262,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
var col = x0;
while (col < x0 + bw) : (col += 1) {
const sidx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page);
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page);
n += 1;
}
}
@@ -3827,7 +4272,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1;
var i: u16 = 0;
while (i < bw) : (i += 1) {
- emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, &g.scroll_edge[i], false, false, page);
+ emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), &g.scroll_edge[i], false, false, page);
n += 1;
}
}
@@ -4318,7 +4763,7 @@ fn drawNativeImagesGpu(
fn renderFrame(
g: *Gui,
gpa: std.mem.Allocator,
- core: *pardes.Pardes,
+ core: ?*const pardes.Pardes,
surface: *pardes.Surface,
theme_bg: ?[3]u8,
topbar_pane_border_rgb: [3]u8,
@@ -4458,9 +4903,9 @@ fn renderFrame(
const data_diff = data_effect and surface.panelCellChanged(col, row);
const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const instance_count = if (data_diff)
- cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row))
+ cellInstanceCount(core, &surface.previous_cells[logical_idx], row) + cellInstanceCount(core, surface.at(col, row), row)
else
- cellInstanceCount(surface.at(col, row));
+ cellInstanceCount(core, surface.at(col, row), row);
paint_plan.batches[batch_index].cell_count = std.math.add(
u32,
paint_plan.batches[batch_index].cell_count,
@@ -4485,7 +4930,7 @@ fn renderFrame(
destination.cell_count = std.math.add(
u32,
destination.cell_count,
- cellInstanceCount(&surface.previous_cells[logical_idx]),
+ cellInstanceCount(core, &surface.previous_cells[logical_idx], row),
) catch return error.GpuCapacity;
}
}
@@ -4779,7 +5224,7 @@ test "tagline bands face the topbar rule and Tagbottom faces the window edge" {
try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h));
}
-fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8) ResolvedCell {
+fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: [3]u8) ResolvedCell {
var fg = fg_default;
var bg = page;
var reverse = is_cursor;
@@ -4803,10 +5248,6 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8)
}
if (reverse) std.mem.swap([3]u8, &fg, &bg);
- const role: pardes.FontRole = if (cell.default)
- .body
- else
- cell.style.font_role;
const cp = cellCodepoint(cell);
return .{
.slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role),
@@ -4816,12 +5257,43 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8)
};
}
+/// What the CORE said this cell's face is.
fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole {
return if (cell.default) .body else cell.style.font_role;
}
-fn cellInstanceCount(cell: *const pardes.Cell) u32 {
- return if (cellFontRole(cell) == .tagline) 2 else 1;
+/// ...and the face it is actually DRAWN in, which differs in exactly one case
+/// and that case is the whole of what an attached window renders differently.
+///
+/// A compact tagline band is anchored at its pane's LEFT EDGE — that is what
+/// `compactTaglineLayout`'s `origin_col` is — and a pane's left edge is a pane
+/// RECT, which this wire does not carry (it carries cells, not the layout that
+/// placed them). Anchoring per cell instead is not a near-miss, it is a picket
+/// fence: `x_off = col * (body_w - tag_w)` puts every cell back on BODY pitch
+/// while the quad stays `tag_w` wide, so the chrome band shows through between
+/// every pair of cells and the text tracks visibly loose. Widening the quad
+/// does not close it either — `emitInstance` samples exactly `tagline_width`
+/// atlas texels for a tagline cell, so a wider quad stretches the glyph.
+///
+/// So a pane tag row with no core to ask goes on the BODY grid, face and all:
+/// one quad per cell at body pitch and body size, tiling exactly and tracking
+/// exactly. The visible difference from a local window is that those rows wear
+/// the body face rather than the 82% one, and that is the price of the pane
+/// rects not being on the wire. It is also the grid `gridCellAtDimensions`
+/// already hit-tests an attached tag row against, so a click still lands on the
+/// glyph it was aimed at.
+///
+/// ROW ZERO is exempt, and that exemption is why the topbar was never striped:
+/// its origin is not a pane rect but column zero, always, so its compact band
+/// is right with or without a core.
+fn drawnFontRole(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) pardes.FontRole {
+ const role = cellFontRole(cell);
+ if (role != .tagline or core != null or row < pardes.TOPBAR_H) return role;
+ return .body;
+}
+
+fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) u32 {
+ return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1;
}
/// A tagline cell has two quads. The first preserves the pane-wide chrome
@@ -4830,7 +5302,7 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 {
/// the compact cell never reaches the next body's cell origin.
fn emitSurfaceCell(
g: *Gui,
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
instances: [*]CellInstance,
next: *u32,
col: u16,
@@ -4845,15 +5317,16 @@ fn emitSurfaceCell(
is_cursor: bool,
page: [3]u8,
) void {
- if (cellFontRole(cell) == .tagline) {
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, tagline_base, old_layer, false, page);
+ const role = drawnFontRole(core, cell, row);
+ if (role == .tagline) {
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, tagline_base, old_layer, false, page);
next.* += 1;
const tag_layout = taglineLayoutForCell(g, core, col, row, track);
- emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, cell, old_layer, is_cursor, page);
+ emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
next.* += 1;
return;
}
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, cell, old_layer, is_cursor, page);
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, cell, old_layer, is_cursor, page);
next.* += 1;
}
@@ -4867,6 +5340,11 @@ fn emitInstance(
win_w: f32,
win_h: f32,
track: ?pardes.panel_animation.Track,
+ /// The face this quad draws in, decided once per cell by `drawnFontRole`
+ /// rather than re-derived here: an attached window demotes a pane tag row
+ /// to the body face, and the quad geometry, the atlas slot and the uv span
+ /// all have to agree about that in one place.
+ role: pardes.FontRole,
cell: *const pardes.Cell,
/// Old and new data layers carry their own quad geometry. The shader
/// discards exactly one at every reveal state, so a body/tagline role
@@ -4876,7 +5354,7 @@ fn emitInstance(
/// the ground this frame: what a default background resolves to
page: [3]u8,
) void {
- const resolved = resolveCell(g, cell, is_cursor, page);
+ const resolved = resolveCell(g, cell, role, is_cursor, page);
// Cell pixel rect (top-left origin) → NDC (y up).
const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
@@ -5001,7 +5479,7 @@ fn firstCp(s: []const u8) u32 {
/// the new cell size is a different grid over the same 2048², and a leftover
/// bitmap no slot points at any more would still be sampled by whatever new
/// slot overlaps it.
-fn refitFont(g: *Gui, core: *pardes.Pardes) void {
+fn refitFont(g: *Gui, core: ?*pardes.Pardes) void {
g.scale = c.ui_font_scale_for_height(g.font, g.px);
var cw: c_int = 10;
var chh: c_int = 20;
@@ -5022,13 +5500,13 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void {
// shells re-derive this every frame anyway, so this is only the frame the
// change happens on — but it is the frame the surface is about to be
// rendered for, and a stale screen_w here is a row of cells drawn off the
- // right edge of the window.
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- _ = updateCoreResize(core, cols, rows, g.cell_w, g.cell_h);
+ // right edge of the window. An attached window has no core to tell: its
+ // loop compares `windowCells` against the last geometry it sent and puts a
+ // resize on the wire from there.
+ if (core) |p| {
+ const geom = windowCells(g);
+ _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h);
+ }
// ...and a fractional scroll is measured in the OLD grid: scroll_rect
// is a rect of the pane the last frame drew, and scroll_edge is a saved row
@@ -5485,7 +5963,7 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
var header: [64]u8 = undefined;
const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed;
- writeFd(fd, hdr);
+ host_io.writeFd(fd, hdr);
const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3);
defer gpa.free(row_rgb);
@@ -5498,7 +5976,7 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
row_rgb[di + 1] = src[si + 1];
row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2];
}
- writeFd(fd, row_rgb);
+ host_io.writeFd(fd, row_rgb);
}
if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed;
}
@@ -5537,7 +6015,7 @@ fn addCursorBar(
fn buildOverlay(
g: *Gui,
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
surface: *const pardes.Surface,
layout: CellLayout,
sw: u32,
@@ -5578,7 +6056,7 @@ fn buildOverlay(
if (surface.cursor) |cursor| {
if (cursor.x < surface.cols and cursor.y < surface.rows) {
const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
- const role = cell.style.font_role;
+ const role = drawnFontRole(core, &cell, cursor.y);
const visual_height: f32 = if (role == .tagline)
@floatFromInt(g.tagline_height)
else
@@ -5868,15 +6346,3 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
const raw = env.get(name) orelse return null;
return std.fmt.parseInt(u16, raw, 10) catch null;
}
-
-fn writeFd(fd: c_int, data: []const u8) void {
- var off: usize = 0;
- while (off < data.len) {
- const n = libc.write(fd, data[off..].ptr, data.len - off);
- if (n < 0) {
- if (libc.errno(n) == .INTR) continue;
- return;
- }
- off += @intCast(n);
- }
-}
diff --git a/src/host.zig b/src/host.zig
index 9ca882b1..b6ccff34 100644
--- a/src/host.zig
+++ b/src/host.zig
@@ -66,6 +66,16 @@ pub const Host = struct {
/// capability handshake landing, gamepad state.
push_poll_frame: ?*const fn (ctx: ?*anyopaque) void = null,
+ // ---- this frontend's own membership ----
+ /// `Detach` — leave the session, which carries on for everybody else.
+ /// Only a DETACHED core's host implements it, and the null case is the
+ /// point rather than an oversight: a local tty or SDL shell has no
+ /// session to leave, so the core reports that on the pane's row (see
+ /// `perform`) instead of quietly quitting something. Argumentless like
+ /// the two frame pushes above, because the host serving it already
+ /// knows whose keystroke arrived — it is the one that delivered it.
+ push_detach: ?*const fn (ctx: ?*anyopaque) void = null,
+
// ---- pseudo-terminals ----
push_spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null,
push_pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null,
diff --git a/src/host_io.zig b/src/host_io.zig
new file mode 100644
index 00000000..001446b1
--- /dev/null
+++ b/src/host_io.zig
@@ -0,0 +1,178 @@
+//! THE MACHINE-LOCAL HALF OF A HOST: fork a pane's shell, put bytes on a disk.
+//!
+//! `host.zig` is the seam — the struct of function pointers the core asks
+//! through. This file is the part of the answer that is the same on every host
+//! that has an operating system under it, and it is now the ONLY copy of it:
+//! tty.zig, detached/server.zig, gui/gui.zig and macos.zig all fork and write
+//! through here. They did not always. Each of the four grew its own `forkShell`
+//! and its own `writeFd`, and what those four copies were for is best said by
+//! what they had in common: ALL FOUR were missing FD_CLOEXEC on the pty master,
+//! so in every shell pardes has ever shipped a program in one pane could read
+//! and write another pane's terminal, and closing a master did not reliably hang
+//! its shell up. One line below fixes that for all four at once (see `forkShell`)
+//! — which is a better argument for this file existing than "it is shared" is.
+//!
+//! Why the daemon and not the frontend does this work: a unix socket means the
+//! core and its frontends are on the SAME machine, so there is no question of
+//! whose disk or whose process table is meant. Given that, the pane shells
+//! belong to the long-lived process, because the whole promise of a detached
+//! session is that it outlives the frontend attached to it — a shell forked by
+//! a frontend dies with that frontend, and then the session has a pane with no
+//! shell in it. The frontend keeps exactly what needs the human's screen: the
+//! grid, the keyboard, the clipboard and a link to open.
+//!
+//! So `forkShell` takes the core it is forking on behalf of and nothing about
+//! terminals: no vaxis, no `Loop`, no reader thread. Who drains the master fd
+//! is the caller's business, and the callers answer differently on purpose. The
+//! tty, gui and macOS shells hand it to a worker that posts into their event
+//! loop; the daemon adds it to the one `poll(2)` it already runs over its
+//! clients, and makes its own copy non-blocking in order to. That last is why
+//! `Child.file.flags` is left saying what it says: the flag describes the
+//! descriptor `forkpty` handed back, for the three callers that stream it, and
+//! the one that polls it keeps only the handle.
+const std = @import("std");
+const posix = std.posix;
+const libc = std.c;
+const pardes = @import("pardes.zig");
+const shell_bin = @import("shell_bin.zig");
+const fs_service = @import("fs_service.zig");
+const fuse = @import("fuse.zig");
+
+/// `setCloexec` and nothing else. Imported rather than copied a fourth time —
+/// fuse.zig and nested.zig each grew a private two-line version of it — because
+/// the descriptor this file has to protect is the one every OTHER file in the
+/// tree already protects, and one predicate is how the reasoning stays in one
+/// place. nested.zig is a leaf (std, builtin, libc), so this costs no
+/// dependency worth the name.
+const nested = @import("nested.zig");
+
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+/// A forked pane shell: the pty master to read and write, and the pid to reap.
+/// Named rather than anonymous because four files now hold one of these.
+pub const Child = struct {
+ file: std.Io.File,
+ pid: posix.pid_t,
+};
+
+/// Fork a shell onto a fresh pty for `pane`, sized `rows`x`cols`.
+///
+/// `core` is optional because a host may fork before it has one, and a core
+/// that is absent simply does not name its shell.
+pub fn forkShell(
+ core: ?*pardes.Pardes,
+ pane: usize,
+ prompt_rcs: *const shell_bin.PromptRcs,
+ bin: []const u8,
+ cwd: ?[*:0]const u8,
+ rows: u16,
+ cols: u16,
+ fs: ?*const fuse.Fs,
+) Child {
+ var master: c_int = undefined;
+ // resolved BEFORE the fork, into this frame, which the child inherits:
+ // nothing between fork and exec may allocate, and a PATH search would
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
+ // ...and so is the pane's own address on the control filesystem, for a
+ // second reason on top of that one: acme puts `winid` in the child, which
+ // is safe there only because rfork(RFENVG) has just given it a private
+ // environment group. See fs_service.exportPaneEnv.
+ fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0);
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ // the blocked-SIGWINCH mask survives fork AND exec — unblock it or
+ // bash/vim in the pane would never see resizes (sigprocmask is
+ // async-signal-safe)
+ var set = posix.sigemptyset();
+ posix.sigaddset(&set, posix.SIG.WINCH);
+ posix.sigprocmask(posix.SIG.UNBLOCK, &set, null);
+ if (cwd) |c| _ = chdir(c);
+ _ = execv(spawn.path, &spawn.argv);
+ _exit(127);
+ }
+ if (pid > 0) {
+ // CLOEXEC ON THE MASTER, and it belongs here rather than at either
+ // caller because `forkpty` is what opens it: /dev/ptmx is opened with no
+ // O_CLOEXEC and there is no flag argument to ask for one. Without this,
+ // every pane shell forked AFTER this one inherits this master and keeps
+ // it across `execv`, which is two bugs at once.
+ //
+ // The loud one: a program running in pane 3 can read pane 0's output and
+ // write bytes into pane 0's screen.
+ //
+ // The silent one, and the reason it compounds: closing a master is the
+ // only thing that hangs its shell up, and a master a later shell still
+ // holds open is not closed. detached/server.zig `closePty` and tty.zig
+ // `spawn` both depend on that hangup, so a pane delete or a respawn left
+ // an orphaned shell that never exits — never reaped, eventually blocked
+ // writing into a pty nobody reads — and each orphan pinned every earlier
+ // pane's master in turn. The startup drain forks pane 0 and then pane 1,
+ // so the arrangement existed from boot, and it existed in all four
+ // copies of this function before they became this one. nested.zig and
+ // fuse.zig say the same thing about their own descriptors ("pane shells
+ // are forked with forkpty and inherit everything open"); the master was
+ // the one descriptor in the tree that nobody had said it to.
+ //
+ // THE WINDOW THIS LEAVES, stated rather than papered over: fcntl after
+ // fork is not atomic, so a thread that forks and execs between these two
+ // syscalls inherits the master anyway. In the detached daemon there is no
+ // such thread — it is single-threaded by construction, which is what
+ // putting the pty masters in its own `poll(2)` bought. The shells with
+ // worker threads that can exec — tty.zig's pipe tasks above all — have a
+ // window two syscalls wide, and closing it means replacing `forkpty` with
+ // our own `posix_openpt(O_CLOEXEC)` / `grantpt` / `unlockpt` / fork /
+ // `setsid`, which is a different change to a different file.
+ nested.setCloexec(master);
+ if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
+ }
+ return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
+}
+
+/// Truncate-or-create `path` and put `bytes` there. False on any failure, and
+/// the caller reports it: a save that did not happen must not be announced as
+/// one.
+pub fn writeFileBytes(path: []const u8, bytes: []const u8) bool {
+ var pathbuf: [4096:0]u8 = undefined;
+ if (path.len >= pathbuf.len) return false;
+ @memcpy(pathbuf[0..path.len], path);
+ pathbuf[path.len] = 0;
+ const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return false;
+ writeFd(fd, bytes);
+ _ = libc.close(fd);
+ return true;
+}
+
+/// A whole-buffer write that finishes short writes, retries EINTR, and refuses
+/// to loop on no progress.
+///
+/// The zero guard is not bookkeeping: without it a `write(2)` that returns 0 for
+/// a nonzero count is an infinite SPIN, because 0 is neither an error nor
+/// progress and `off` never moves. macos.zig's copy carried the guard and its
+/// reason all along — "a zero-byte write makes no progress; looping on it would
+/// spin the main thread forever" — and the tty copy this file was extracted
+/// from did not, so the extraction briefly promoted the weakest of the three to
+/// being the shared one. All three are now this one: gui.zig and macos.zig were
+/// migrated onto it, so the guard is no longer missing anywhere.
+///
+/// A spin is strictly worse than the block it replaces, which is why this
+/// matters more now that detached/server.zig reaches this file from a
+/// single-threaded poll loop: a blocked `write` is one syscall a signal can
+/// interrupt, and a spin is 100% of a core with the whole session behind it.
+pub fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const n = libc.write(fd, data[off..].ptr, data.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return;
+ }
+ if (n == 0) return;
+ off += @intCast(n);
+ }
+}
diff --git a/src/limits.zig b/src/limits.zig
index 965f4d0a..75bf5262 100644
--- a/src/limits.zig
+++ b/src/limits.zig
@@ -77,6 +77,21 @@ pub const board_heap_bytes = 384 * KiB;
/// `emitWrite`, i.e. only if a pty ever appears on this platform.
pub const effect_cap = if (board) 128 else 4096;
+/// Bytes of a pane's pty write that may WAIT in the core when `effect_cap`
+/// chunks are already queued. `emitWrite` splits a burst into fixed 64-byte
+/// effects, so without this a paste larger than `effect_cap * 64` (256 KiB on
+/// a desktop) lost its tail silently — the ring refuses rather than evicts,
+/// which keeps queued bytes in order but cut the new ones off. The remainder
+/// parks here instead and `nextEffect` refills the ring as the host drains it,
+/// so a large paste is DELAYED rather than truncated.
+///
+/// 4 MiB matches `tty.max_paste_bytes`, the largest burst a host can hand the
+/// core in one event, so the bound is the one the producer already enforces.
+/// Zero on the board: no ptys means no `emitWrite`, and the allocation this
+/// would justify cannot live in 384 KiB anyway. A zero cap parks nothing and
+/// restores the old refusal exactly.
+pub const pending_write_cap: usize = if (board) 0 else 4 << 20;
+
/// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane
/// taller than this (it reads the array's own length), so shrinking it cannot
/// truncate a map — a taller pane renders unwrapped, exactly as documented on
diff --git a/src/macos.zig b/src/macos.zig
index e54e013c..2b04237e 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -35,11 +35,8 @@ const file_watch = @import("file_watch.zig");
/// attachments, so nothing here is analysed.
const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
const user_config = @import("user_config.zig");
+const host_io = @import("host_io.zig");
-extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
-extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
-extern "c" fn chdir(path: [*:0]const u8) c_int;
-extern "c" fn _exit(status: c_int) noreturn;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
/// Implemented by FileWatcher.swift in the app and e2e host. Zig-only unit
@@ -1807,7 +1804,7 @@ fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w, null);
st.ptys[pane] = .{
.file = child.file,
.pid = child.pid,
@@ -1823,7 +1820,7 @@ fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
const st = hostState(ctx);
- if (st.ptys[pane]) |pt| writeFd(pt.file.handle, bytes);
+ if (st.ptys[pane]) |pt| host_io.writeFd(pt.file.handle, bytes);
}
fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
@@ -1848,14 +1845,7 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
/// resolved which path and which bytes.
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const st = hostState(ctx);
- var pathbuf: [4096:0]u8 = undefined;
- if (path.len >= pathbuf.len) return;
- @memcpy(pathbuf[0..path.len], path);
- pathbuf[path.len] = 0;
- const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- writeFd(fd, bytes);
- _ = libc.close(fd);
+ if (!host_io.writeFileBytes(path, bytes)) return;
// The directory source will observe our own close. Move its baseline first
// so that notification is a hash no-op instead of manufacturing an external
// reload and undo boundary.
@@ -1870,10 +1860,7 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const st = hostState(ctx);
var pbuf: [1024:0]u8 = undefined;
const path = pardes.dump.outPath(&pbuf) orelse return;
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- writeFd(fd, bytes);
- _ = libc.close(fd);
+ if (!host_io.writeFileBytes(path, bytes)) return;
st.core.setLastDump(path);
}
@@ -1988,40 +1975,6 @@ fn wake(st: *State) void {
// ---------------------------------------------------------------- helpers
-fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
- var master: c_int = undefined;
- // Resolved BEFORE the fork, into this frame, which the child inherits:
- // nothing between fork and exec may allocate, so a PATH search cannot
- // happen there.
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- if (cwd) |c| _ = chdir(c);
- _ = execv(spawn.path, &spawn.argv);
- _exit(127);
- }
- if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
- return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
-}
-
-fn writeFd(fd: c_int, data: []const u8) void {
- var off: usize = 0;
- while (off < data.len) {
- const n = libc.write(fd, data[off..].ptr, data.len - off);
- if (n < 0) {
- if (libc.errno(n) == .INTR) continue;
- return;
- }
- // A zero-byte write makes no progress; looping on it would spin the
- // main thread forever, which here means a beachball rather than the
- // tty shell's hung terminal.
- if (n == 0) return;
- off += @intCast(n);
- }
-}
-
/// Rebuild the process environment as a Map, because a library never sees the
/// std.process.Init that main() gets one from. Only the config-path lookup
/// reads it, and the arena owns the copies for the life of the process.
diff --git a/src/main.zig b/src/main.zig
index 08beae86..b8cb6b2d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -93,15 +93,30 @@ const help_text =
\\ a frontend can say which session it wants. One
\\ path component: no '/' and nothing empty
\\ --attach become a frontend of the one detached session
- \\ that is running: draw its screen, send it input,
- \\ fork its pane shells. Several frontends may be
- \\ attached at once and all see the same screen
+ \\ that is running: draw its screen and send it
+ \\ input, and nothing else — the session owns its
+ \\ pane shells, its files and its watches. Works in
+ \\ both the terminal and the SDL window build.
+ \\ Several frontends may be attached at once and all
+ \\ see the same screen
\\ --attach=<name> ...of the session called <name>, which is what to
\\ use when more than one is running
\\ -h, --help show this help and exit
+ \\ --version print the version and exit
\\
;
+/// Built at COMPTIME, because both halves are: `version` comes out of
+/// `build.zig.zon` through the options module and `commit` out of `git` at
+/// configure time, so there is nothing here to format at runtime and no buffer
+/// to size. The commit is in parentheses when there is one and absent
+/// otherwise — a build from a tarball says `pardes 0.0.1` and is not lying
+/// about a revision it never had. See `pardes.version`/`pardes.commit`.
+const version_text = if (pardes.commit) |c|
+ "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n"
+else
+ "pardes " ++ pardes.version ++ "\n";
+
const nested_text =
\\pardes: this shell is already inside pardes, and a pardes inside a pardes
\\is spicy. Name a file or a directory and the outer session opens it, or
@@ -129,9 +144,10 @@ fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int {
return 0;
}
-// no argv in the browser: options stay default, state comes from the dump
+// no argv in the browser: options stay default, state comes from the dump, and
+// there is no session to attach to — a browser tab has no unix socket.
fn webMain() !void {
- try @import("gui/gui.zig").run(.{});
+ try @import("gui/gui.zig").run(.{}, .{}, null);
}
fn nativeMain(init: std.process.Init) !void {
@@ -159,8 +175,8 @@ fn nativeMain(init: std.process.Init) !void {
// free to mean "the default name" the way opts.fs uses it for a directory.
var detach: ?[]const u8 = null;
// ...and `--attach[=<name>]`, the same shape: null when it was not given,
- // and the empty string means "the one session there is" (tty.zig
- // `sessionName`) rather than a session with no name.
+ // and the empty string means "the one session there is"
+ // (detached_client.resolve) rather than a session with no name.
var attach: ?[]const u8 = null;
// Kept RAW until every flag is parsed: classifying it means chdir'ing into
// a directory and recording nothing, and the nested client below still
@@ -216,6 +232,9 @@ fn nativeMain(init: std.process.Init) !void {
} else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
try std.Io.File.stdout().writeStreamingAll(init.io, help_text);
return;
+ } else if (std.mem.eql(u8, a, "--version")) {
+ try std.Io.File.stdout().writeStreamingAll(init.io, version_text);
+ return;
} else if (a.len > 0 and a[0] != '-' and positional == null) {
positional = a;
} else {
@@ -285,6 +304,19 @@ fn nativeMain(init: std.process.Init) !void {
// reading. Refused rather than resolved by declaration order, which would
// silently drop whichever flag lost.
if (detach != null and attach != null) return error.BadArgs;
+ // `--fs` mounts the acme control filesystem, and only a LOCAL session has
+ // one: `fs_service.start` is called inside tty.zig's `localSession` and
+ // gui.zig's equivalent, both of which an `--attach` skips entirely, and
+ // `detached/server.zig` never reads `opts.fs` at all. So `--fs` with either
+ // of these was parsed, stored, and then served by nobody.
+ //
+ // Refused for the same reason as the line above, and it is the stronger
+ // case: a contradiction is at least visible, whereas a silently dropped
+ // mount is invisible until someone waits for a directory that will never
+ // appear. Serving it instead would mean mounting FUSE in the detached core,
+ // which is a feature rather than a fix — `push_fs_reply` is one of the five
+ // host methods that core deliberately leaves null (docs/detached.md).
+ if (opts.fs != null and (detach != null or attach != null)) return error.BadArgs;
// `--detach` replaces the frontend rather than choosing among them: the
// core runs here, with no terminal, and the frontends are elsewhere on a
// socket (src/detached/). It is checked before `platform` because it is not
@@ -300,15 +332,18 @@ fn nativeMain(init: std.process.Init) !void {
try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))});
return @import("detached/server.zig").run(init, opts, named);
}
- // A frontend is a SHELL, and only the tty one knows how to be one today.
- // Comptime-folded, so a tty build carries none of this.
- if (attach != null and pardes.platform != .tty) {
- try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty shell\n");
+ // A frontend is a SHELL, and the two native ones — a terminal and an SDL
+ // window — both know how to be one. The browser has no unix socket to
+ // reach a session over and the AppKit shell is entered by its own host
+ // rather than through this file, so neither is wired for it; the check is
+ // comptime-folded, so a tty or gui build carries none of it.
+ if (attach != null and pardes.platform != .tty and pardes.platform != .gui) {
+ try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty or gui shell\n");
std.process.exit(1);
}
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts, attach),
- .gui => try @import("gui/gui.zig").run(init, opts),
+ .gui => try @import("gui/gui.zig").run(init, opts, attach),
// Every other shell is entered by its host and never links this file
// at all: the browser through src/web.zig, the macOS app through
// src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig,
diff --git a/src/nested.zig b/src/nested.zig
index 887d0703..1997d8e4 100644
--- a/src/nested.zig
+++ b/src/nested.zig
@@ -82,11 +82,15 @@ extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaq
/// The gap is a race only against a fork on another thread, and every caller
/// is past that: `listen` runs before the first pane exists, `acceptLine` runs
/// on a thread of its own long after spawning has settled, and the detached
-/// session forks nothing at all (its ptys live in its frontends).
+/// session — which forks EVERY pane shell in the session, because the daemon
+/// owns them now (`host_io.zig`) — has no other thread to race with, since it
+/// services its pane ptys from the same `poll(2)` that accepts its frontends.
///
-/// CLOEXEC still matters for both: a `--detach` session is long-lived, and an
-/// inherited listener would keep its socket bound long after it ended — the
-/// same shape as the inherited lock fd that once held a flock forever.
+/// CLOEXEC matters MORE for that last one than it did when a frontend forked
+/// the shells: a pane shell is long-lived and arbitrary, and an inherited
+/// listener would keep the session's socket bound long after the session
+/// ended — the same shape as the inherited lock fd that once held a flock
+/// forever.
pub fn setCloexec(fd: c_int) void {
const FD_CLOEXEC: c_int = 1;
_ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC);
diff --git a/src/pardes.zig b/src/pardes.zig
index db20ff32..55461c5d 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -82,6 +82,22 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config"
/// target - see `build.zig`.
pub const theme_animation = @import("pardes_config").theme_animation;
+/// WHICH BUILD THIS IS, for `--version` and for any bug report that follows it.
+///
+/// `version` is `build.zig.zon`'s `.version`, read from the manifest by
+/// `build.zig` rather than copied beside it, so there is exactly one place to
+/// bump. `commit` is the git revision it was built from, and it is OPTIONAL
+/// because a source drop is not a repository: a tarball, a container with no
+/// `git`, or any checkout outside version control all yield null, and a
+/// frontend must say the version happily without one.
+///
+/// Both are strings the build baked in, never questions asked at runtime. A
+/// binary that shelled out to `git` would describe whatever tree it was
+/// standing in rather than the one it came from — and on the board there is
+/// neither a `git` nor a process to run it with.
+pub const version = @import("pardes_config").version;
+pub const commit: ?[]const u8 = @import("pardes_config").commit;
+
/// A build with no host but its display: the embedded source filesystem, the
/// in-process clipboard, silent ptys. Comptime, and its own option module
/// rather than a `pardes_config` field, because it is the one setting that
@@ -100,6 +116,22 @@ pub const font_picker = platform == .gui or platform == .macos;
/// reads this instead so a third such platform cannot forget one of them.
pub const hosted = platform == .tty or platform == .gui or platform == .macos;
+/// Builds whose frontend can hand its screen to a detached core — which is
+/// narrower than `hosted`, and the gap is a bug this predicate exists to close.
+///
+/// macOS is hosted, has a unix socket, and compiles `detached/`; what it does
+/// not do is POLL. `takeAttach` is a poll rather than a host method precisely
+/// because attaching replaces the core the call is running inside (see
+/// `Effect.attach`), and `src/macos.zig` never calls it. Gated on `hosted`, the
+/// `Attach` word therefore parsed, queued an effect, stored a request in
+/// `attach_buf` — and did nothing at all, for ever, silently. That is the
+/// failure this codebase refuses everywhere else, so the word does not exist
+/// on a frontend that cannot serve it.
+///
+/// The two here are exactly the two `main.zig` accepts `--attach` for, which is
+/// the same question asked at the command line instead of in a tag.
+pub const can_attach = platform == .tty or platform == .gui;
+
/// Builds that HAVE terminal panes: a pane whose content is a live ghostty-vt
/// emulator being fed pty bytes. The P4 firmware has no processes, no ptys and
/// nothing that could produce a VT byte, so there the emulator is ~400 KiB of
@@ -288,6 +320,67 @@ fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 {
return if (len) |n| buf[0..n] else null;
}
+/// Drain the queue through the in-process host — what a shell's pump does with
+/// it — and report the Attach among those effects. Going through `perform` is
+/// the point: what a frontend acts on is what `takeAttach` hands back AFTER the
+/// drain, not the effect value, which dies in the loop that read it.
+fn drainForAttach(p: *Pardes) ?AttachRequest {
+ while (p.nextEffect()) |effect| p.perform(effect);
+ return p.takeAttach();
+}
+
+test "Attach asks for a session and tears nothing down" {
+ if (comptime !hosted) return; // no unix socket on this platform, so no word
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ // Bare means whichever session is there, and that is an EMPTY name rather
+ // than an absent request: the frontend still has to go and look.
+ try std.testing.expect(p.executeBuiltinLine(0, "Attach"));
+ const bare = drainForAttach(p) orelse return error.NoAttachAsked;
+ try std.testing.expectEqualStrings("", bare.name);
+ try std.testing.expectEqual(@as(u8, 0), bare.pane);
+
+ // ...and the named form carries its tail, the way `Theme <name>` does.
+ try std.testing.expect(p.executeBuiltinLine(0, "Attach work"));
+ const named = drainForAttach(p) orelse return error.NoAttachAsked;
+ try std.testing.expectEqualStrings("work", named.name);
+ try std.testing.expect(p.takeAttach() == null); // taken once, then gone
+
+ // The word only ASKS. A core that tore itself down here could not be handed
+ // back intact when the connect fails, and that is the entire guarantee.
+ try std.testing.expect(!p.quit);
+ try std.testing.expect(p.panes[0] != null);
+}
+
+test "Detach asks the frontend to leave, and says so when there is nothing to leave" {
+ if (comptime !hosted) return; // no unix socket on this platform, so no word
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ // Whole-word only, like Kill: the effect names the pane that ran it and
+ // carries nothing else, because the daemon that serves it already knows
+ // which frontend's keystroke arrived.
+ try std.testing.expect(p.executeBuiltinLine(0, "Detach"));
+ const asked = while (p.nextEffect()) |effect| switch (effect) {
+ .detach => |d| break d,
+ else => {},
+ } else return error.NoDetachAsked;
+ try std.testing.expectEqual(@as(u8, 0), asked.pane);
+
+ // ...and this core is a LOCAL shell — a bare `Host{}` fills in no
+ // `push_detach` — so performing it reports on that pane instead of
+ // dismissing a session this process is not part of.
+ p.perform(.{ .detach = asked });
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]);
+ try std.testing.expect(!p.quit);
+}
+
test "selection pipe prompt submits exact request and Escape cancels" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -3697,6 +3790,25 @@ pub const Event = union(enum) {
fs_req: acmefs.Req,
};
+/// The longest session name `Effect.attach` can carry. A name is ONE path
+/// component under the runtime socket directory (detached/server.zig
+/// `socketPath`), so `sun_path`'s 108 bytes cap a usable one far below this;
+/// 256 is the width `spawn`'s cwd and `open_link` already reserve, and reusing
+/// it is why the new arm costs the effect ring nothing — `save_text`'s
+/// {pane, serial, Buf(256)} is still the widest thing in the union.
+pub const attach_name_max = 256;
+
+/// What `takeAttach` hands the shell: the session to reach for (empty means
+/// "whichever one is there") and the pane whose message row a failed connect
+/// is reported on, the way `Effect.dump_themes` carries the pane that receives
+/// the native host's answer.
+pub const AttachRequest = struct { pane: u8, name: []const u8 };
+
+/// A pane's pty bytes waiting for room in the effect ring. Core-owned (the
+/// `Event.paste` slice they came from is borrowed for one `update` only), and
+/// freed the moment `off` reaches the end or the pane goes away.
+pub const PendingWrite = struct { bytes: []u8, off: usize = 0 };
+
/// IO the core wants done. Payloads are inline (fixed buffers): effects are
/// queued values with no lifetime ties back into the core.
pub const Effect = union(enum) {
@@ -3758,6 +3870,27 @@ pub const Effect = union(enum) {
/// blocking `event` read, with the waiting left where the kernel's
/// request already is.
fs_reply: acmefs.Reply,
+ /// `Attach [name]` — hand this frontend's screen to a detached core, the
+ /// one `pardes --detach [name]` left running; an empty name means "the
+ /// session that is there". `pane` is where a failed connect is reported.
+ ///
+ /// CONNECT FIRST, SWAP SECOND is what the shell owes this, and it is the
+ /// whole point of the word: the session's socket must be open before
+ /// anything local is torn down, so an attach that fails leaves this
+ /// instance running with every pane and every undo intact instead of half
+ /// dead. Which is also why no host method performs it — see `perform`.
+ attach: struct { pane: u8, name: Buf(attach_name_max) },
+ /// `Detach` — this frontend leaves; the session and every other frontend
+ /// carry on. tmux's detach-client, and deliberately NOT the inverse of
+ /// `attach`: turning a live LOCAL session into a daemon needs setsid and a
+ /// fork, or closing the terminal takes the session with it.
+ ///
+ /// No name travels because there is nobody to name. The word is typed in a
+ /// frontend that has no core of its own, reaches the daemon as an ordinary
+ /// `Event.command`, and the daemon routes the effect back to the frontend
+ /// whose keystroke caused it. `pane` is only for the report a local shell
+ /// gets instead — `perform`'s null-method arm.
+ detach: struct { pane: u8 },
quit,
fn Buf(comptime n: usize) type {
@@ -5834,6 +5967,19 @@ pub const Pardes = struct {
effects_head: usize = 0,
effects_len: usize = 0,
+ /// Pty bytes that did not fit the ring, per pane, kept because refusing a
+ /// `write` TRUNCATES a byte stream rather than merely delaying it: a 1 MiB
+ /// paste used to reach a program as its first 256 KiB, silently. `emitWrite`
+ /// parks the tail here and `nextEffect` refills the ring from it as the host
+ /// drains, so the stream is delayed and never cut. See `limits.pending_write_cap`.
+ ///
+ /// Per pane because two ptys are independent streams: only order WITHIN one
+ /// matters, so a pane whose tail is waiting never delays another's writes.
+ pending_write: [MAX_PANES]?PendingWrite = @splat(null),
+ /// Total bytes parked above, so the drain path costs one comparison when
+ /// nothing is waiting — which is every frame that is not a large paste.
+ pending_write_bytes: usize = 0,
+
/// WHO SERVES THIS CORE. Every method optional; a null one is answered by
/// `fallback` below, so a `Host{}` is a complete in-process pardes.
host: Host = .{},
@@ -5864,6 +6010,14 @@ pub const Pardes = struct {
/// shell consumes it via takeRestore each frame (restore contents stay host-fed)
restore_req: ?[]const u8 = null,
restore_buf: [1024]u8 = undefined,
+ /// An Attach builtin wants this frontend's screen handed to a detached
+ /// core; the shell consumes it via takeAttach from its OUTER loop, beside
+ /// takeRestore and for the same reason — both END this core, and nothing
+ /// running inside `pump` may destroy the core it is running in. `name`
+ /// points into `attach_buf`, which the effect's inline copy is unpacked
+ /// into: the effect is a value the drain loop owns and dies with it.
+ attach_req: ?AttachRequest = null,
+ attach_buf: [attach_name_max]u8 = undefined,
surface: Surface = .{},
/// per-update scratch (paneCursorLines, selection text); reset each update
@@ -5987,6 +6141,7 @@ pub const Pardes = struct {
pub fn deinit(p: *Pardes) void {
p.cancelLookHover();
+ for (0..MAX_PANES) |id| p.dropPendingWrite(id);
for (&p.panes) |*slot| if (slot.*) |pane| {
p.teardownPane(pane);
slot.* = null;
@@ -6033,6 +6188,14 @@ pub const Pardes = struct {
return r;
}
+ /// the shell polls this each frame beside takeRestore: a pending Attach's
+ /// session and reporting pane, or null
+ pub fn takeAttach(p: *Pardes) ?AttachRequest {
+ const a = p.attach_req;
+ p.attach_req = null;
+ return a;
+ }
+
/// the topbar line: the fixed builtins, plus `Restore <path>` once a dump
/// exists — render and click dispatch must agree on this exact string
fn topbar(p: *Pardes, buf: []u8) []const u8 {
@@ -6056,6 +6219,8 @@ pub const Pardes = struct {
// `event` file open leaving the editor suppressing button actions
// for whatever pane lands in this slot next.
p.fs.forget(p.gpa, id);
+ // ...and so do bytes still queued for the pty it no longer has.
+ p.dropPendingWrite(id);
};
if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover();
p.pane_alloc.doom(pane);
@@ -6221,14 +6386,47 @@ pub const Pardes = struct {
p.look_walk_owner = pane.serial;
}
- /// chunk arbitrary-length bytes into fixed-size write effects (order kept)
+ /// Chunk arbitrary-length bytes into fixed-size write effects, order kept.
+ ///
+ /// The ring refuses when full rather than evicting, which is right for every
+ /// other effect and WRONG for a byte stream: the tail of a large paste was
+ /// dropped where the program needed it whole (and, under bracketed paste, the
+ /// closing marker went with it, leaving the program in paste mode). What does
+ /// not fit parks in `pending_write` and `nextEffect` feeds it back as the host
+ /// drains, so this never truncates while `pending_write_cap` has room.
pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
var off: usize = 0;
- while (off < bytes.len) {
- const n = @min(bytes.len - off, 64);
- p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
- off += n;
+ // Anything already parked for this pane owns the stream's position, so
+ // new bytes queue BEHIND it — emitting them now would reorder the pty.
+ if (p.pending_write[id] == null) {
+ while (off < bytes.len and p.effects_len < p.effects.len) {
+ const n = @min(bytes.len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
+ off += n;
+ }
}
+ if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]);
+ }
+
+ /// Take ownership of bytes the ring had no room for. A failed allocation or
+ /// an exhausted cap degrades to the old behaviour — dropping the tail — because
+ /// the alternative on a full heap is refusing to run at all.
+ fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void {
+ if (comptime limits.pending_write_cap == 0) return;
+ if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return;
+ if (p.pending_write[id]) |*pw| {
+ // Compact what the drain already sent before growing: a burst that
+ // arrives in pieces must not keep re-copying bytes nobody wants.
+ const keep = pw.bytes.len - pw.off;
+ const grown = p.gpa.alloc(u8, keep + bytes.len) catch return;
+ @memcpy(grown[0..keep], pw.bytes[pw.off..]);
+ @memcpy(grown[keep..], bytes);
+ p.gpa.free(pw.bytes);
+ pw.* = .{ .bytes = grown };
+ } else {
+ p.pending_write[id] = .{ .bytes = p.gpa.dupe(u8, bytes) catch return };
+ }
+ p.pending_write_bytes += bytes.len;
}
/// The shell reports the spawned pane's working directory (and later cwd
@@ -6533,6 +6731,10 @@ pub const Pardes = struct {
}
pub fn nextEffect(p: *Pardes) ?Effect {
+ // Before the emptiness test, not after: a pane whose tail is parked
+ // must not read as "no effects left" while the host's drain loop is
+ // still asking. This is what turns a truncated paste into a delayed one.
+ p.refillPendingWrites();
if (p.effects_len == 0) {
p.effects_head = 0;
return null;
@@ -6543,6 +6745,38 @@ pub const Pardes = struct {
return e;
}
+ /// Move parked pty bytes into whatever room the ring now has, oldest pane
+ /// slot first. Costs one comparison when nothing is parked.
+ fn refillPendingWrites(p: *Pardes) void {
+ if (p.pending_write_bytes == 0) return;
+ for (&p.pending_write, 0..) |*slot, id| {
+ while (p.effects_len < p.effects.len) {
+ // `|*pw|` points INTO the slot: capturing by value would advance
+ // a copy's cursor and re-send the same chunk for ever.
+ const pw = if (slot.*) |*live| live else break;
+ const n = @min(pw.bytes.len - pw.off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pw.bytes[pw.off..][0..n]) } });
+ pw.off += n;
+ p.pending_write_bytes -= n;
+ if (pw.off == pw.bytes.len) {
+ p.gpa.free(pw.bytes);
+ slot.* = null;
+ break;
+ }
+ }
+ if (p.effects_len == p.effects.len) return;
+ }
+ }
+
+ /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied
+ /// may be handed to a different pane next frame.
+ fn dropPendingWrite(p: *Pardes, id: usize) void {
+ const pw = p.pending_write[id] orelse return;
+ p.pending_write_bytes -= pw.bytes.len - pw.off;
+ p.gpa.free(pw.bytes);
+ p.pending_write[id] = null;
+ }
+
/// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an
/// event that carries a borrowed slice cannot survive the trip, so this
/// asserts rather than documents it. Hand those to `update` directly inside
@@ -6683,6 +6917,33 @@ pub const Pardes = struct {
// this is the borrow window. A host with no filesystem serving
// cannot have asked, so a null method is not a dropped answer.
.fs_reply => |r| if (v.push_fs_reply) |f| f(p.host.ctx, &r, p.fsPayload(r)),
+ // THE ONE EFFECT NO HOST METHOD CAN SERVE: attaching REPLACES the
+ // core this call is running inside — `pump` is two frames up the
+ // stack — so all it may do here is record the request where the
+ // shell's outer loop finds it, which is Restore's shape exactly.
+ // Reaching it through the ring rather than straight from the
+ // builtin is what ORDERS it: a `Save` queued by the same update is
+ // performed first, so nothing you typed is still unwritten when
+ // the screen changes owners. A shell that never polls (the
+ // browser, the board) simply cannot attach, which is the truth
+ // about a machine with no unix socket to attach to.
+ .attach => |a| {
+ const name = a.name.slice();
+ @memcpy(p.attach_buf[0..name.len], name);
+ p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] };
+ },
+ // ...and its counterpart, which a host CAN serve and usually does
+ // not. Only a detached core's host fills `push_detach` in; a local
+ // tty or SDL shell leaves it null, and the honest answer there is
+ // a message row rather than a frontend that quits or a word that
+ // silently does nothing. A null-method fallback and not a comptime
+ // gate, because whether there is a session to leave is a fact
+ // about this RUN — the same binary attaches one minute and does
+ // not the next.
+ .detach => |d| if (v.push_detach) |f|
+ f(p.host.ctx)
+ else
+ p.reportError(d.pane, "detach", error.NotAttached),
// the loop's own condition; a host tears down after its own loop
.quit => p.quit = true,
}
@@ -13423,15 +13684,37 @@ pub const Pardes = struct {
// ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
/// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal
- /// cursor there (`at.col` likewise, 0 = line start): files recenter the
- /// view on it, terminals ride their scrollback to it. Both look targets
+ /// cursor there (`at.col` likewise, 0 = line start). `landing` says how far
+ /// the view may MOVE to show it: `.center` recenters a file on the line and
+ /// reveals a PDF's page — a look target, a `:NN`, a search hit, where the
+ /// context around the destination is the whole point of going there — while
+ /// `.keep` leaves the view alone and lets `ensureCursorVisible` do the least
+ /// that shows the cursor, usually nothing at all. A terminal has no recenter
+ /// to skip, so `landing` does not gate it — but it is not therefore free of
+ /// movement: a modal cursor PINNED high in the scrollback still pulls the
+ /// view up to it, which is `ensureCursorVisible` keeping its promise and the
+ /// reason `Last` records `line = 0` for an unpinned shell. Both look targets
/// that name a live pane land here — a path a pane already holds, and
/// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below).
- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot) void {
+ pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void {
if (id >= MAX_PANES) return;
const pane = p.panes[id] orelse return;
p.active = id;
if (hasPdf(pane)) {
+ // A page reveal IS this pane's view, so `.keep` is simply not doing
+ // it. Not a formality: a reveal of the page you are already on still
+ // sets `document_scroll_y` to `page_starts[page]`, so returning to a
+ // PDF threw away the offset WITHIN the page you were reading.
+ //
+ // Read that literally — under `.keep` a PDF's page is not restored
+ // AT ALL, and the spot's line is a page. That only shows when
+ // something moved the pane while you were away, and something can:
+ // the wheel scrolls the pane under the POINTER, not the active one.
+ // Then Esc leaves the PDF on the page the wheel reached rather than
+ // the one the jumplist recorded, which is the answer a RETURN wants
+ // and not the answer a jump wants — so Ctrl-o and Ctrl-i, which
+ // centre, are still how you reach the recorded page.
+ if (landing == .keep) return;
if (comptime pdf_enabled) {
const pv = &pane.pdf.?;
if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane);
@@ -13440,16 +13723,23 @@ pub const Pardes = struct {
}
if (at.line == 0) return;
if (pane.file) |*f| {
+ // The clamp holds either way: a stale jump naming a line past the
+ // end must not land the cursor there just because the view is not
+ // moving.
if (at.line > file_pane.nlines(p.gpa, f)) return;
- const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
- if (next != f.scroll) {
- f.scroll = next;
- f.syntax_dirty = true;
+ if (landing == .center) {
+ const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
}
}
- // land the modal cursor on the target line (and keep
- // ensureCursorVisible agreeing with the recenter — a stale cursor
- // would yank the view right back)
+ // Land the modal cursor on the target line. Under `.center` that keeps
+ // ensureCursorVisible agreeing with the recenter — a stale cursor would
+ // yank the view right back. Under `.keep` it IS the whole policy: no
+ // recenter ran, so the nudge below is the only thing that can move the
+ // view, and it moves it only far enough to show the cursor.
pane.cur_row = @intCast(at.line - 1);
pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0;
pane.cur_pinned = true;
@@ -13495,12 +13785,12 @@ pub const Pardes = struct {
return true;
};
if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) {
- p.focusPaneLine(i, at);
+ p.focusPaneLine(i, at, .center);
return true;
};
const ff = if (tt.file) |*f| f else continue;
if (!std.mem.eql(u8, ff.path, path)) continue;
- p.focusPaneLine(i, at);
+ p.focusPaneLine(i, at, .center);
return true;
}
return false;
@@ -13606,7 +13896,7 @@ pub const Pardes = struct {
// `@p7:10:5`: pane 7, line 10, column 5 — how a search result
// points at a terminal or an output buffer, neither of which
// has a path.
- .pane => |t| p.focusPaneLine(t.id, t.at),
+ .pane => |t| p.focusPaneLine(t.id, t.at, .center),
.url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
.dir => |dir| {
// focus an existing terminal on this dir, else fork one below.
@@ -14303,12 +14593,20 @@ pub const Pardes = struct {
/// the stack and go to what it names. Nothing is pushed and nothing is
/// dropped — walking history is not making it — and trackJump agrees,
/// because after the move the live spot IS `jumps[jcur]` again.
+ ///
+ /// `.center` where `Last` keeps the view, and the asymmetry is structural
+ /// rather than arbitrary: `Last` only ever CROSSES panes, so the pane it
+ /// lands on already holds the view you left it with. This may land in the
+ /// SAME pane, where there is no such view to keep — a long in-file jump
+ /// would arrive on the very top or bottom row with `scroll_off` lines of
+ /// context on one side. Helix splits the same pair the same way: its
+ /// jumplist centres, its buffer switch does not.
pub fn jumpBy(p: *Pardes, delta: i32) void {
const next = @as(i64, @intCast(p.jcur)) + delta;
if (p.njumps == 0 or next < 0 or next >= p.njumps) return;
p.jcur = @intCast(next);
const j = p.jumps[p.jcur];
- p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col });
+ p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center);
}
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
@@ -15418,7 +15716,16 @@ pub const Pardes = struct {
// this same choice named. Order is load-bearing — gutter, recolor, then
// wrap markers; the selection/cursor passes below win over all three.
switch (pane.colorAlgo()) {
- .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h),
+ // Every mode, not just `.tty`: `recolorAnsi` translates a row's
+ // colour anchor through the same slide the edit buffer applied to
+ // its text, so leaving a shell for normal mode no longer drains
+ // the screen of colour. A row the user typed has no ANSI and is
+ // skipped there, which is why this needs no mode test.
+ // `body` is the very text printed above: `recolorAnsi` pairs its
+ // graphemes with the cells that spelled them, which is the only way
+ // to stay on the right glyph when the emulator and this surface
+ // disagree about how many columns a cluster is worth.
+ .tty => if (p.settings.colors) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, body),
.source, .diff => {
const f = &pane.file.?;
file_pane.drawGutter(p, pane, r, tx, tw, body_h, active);
@@ -15792,6 +16099,50 @@ test "Esc back into a tty leaves its view at the prompt" {
try std.testing.expectEqual(live, sp.vt.screens.active.pages.scrollbar().offset);
}
+test "Esc back into a file leaves its view where it was" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/allocators.zig" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ const shell = p.active;
+ p.update(.{ .key = .{ .cp = Key.escape } }); // back onto the doc
+ p.sync();
+ const doc = p.active;
+ try std.testing.expect(doc != shell);
+ const dp = p.panes[doc].?;
+ try std.testing.expect(dp.file != null);
+
+ // Ride the cursor down until the view has scrolled: it now sits in the
+ // bottom band, which is exactly where a recenter is visible.
+ for (0..20) |_| p.update(.{ .key = .{ .cp = 'j' } });
+ p.sync();
+ const view = dp.file.?.scroll;
+ const row = dp.cur_row;
+ try std.testing.expect(view > 0);
+
+ p.update(.{ .key = .{ .cp = Key.escape } }); // out to the shell
+ p.sync();
+ try std.testing.expectEqual(shell, p.active);
+ p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back
+ p.sync();
+ try std.testing.expectEqual(doc, p.active);
+
+ // Nothing moved. The old landing recentred the line under the cursor, so
+ // coming back repainted the whole screen to show what was already on it.
+ try std.testing.expectEqual(view, dp.file.?.scroll);
+ try std.testing.expectEqual(row, dp.cur_row);
+ // ...and the cursor is still on screen, which is all `.nearest` promises.
+ try std.testing.expect(dp.cur_row >= @as(i32, @intCast(view)));
+ try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows));
+
+ // The other arm still centres: a look target, a `:NN`, a search hit.
+ p.focusPaneLine(doc, .{ .line = @intCast(row + 1), .col = 1 }, .center);
+ try std.testing.expect(dp.file.?.scroll != view);
+ try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll);
+}
+
test "Shift-Esc in tty hops to the doc and leaves the shell in tty" {
if (platform == .web) return;
const gpa = std.testing.allocator;
@@ -16017,6 +16368,79 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" {
try std.testing.expect(pane.ovl == null);
}
+test "a paste larger than the effect ring reaches the program whole and in order" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const buf = try gpa.alloc(u8, 1 << 20);
+ defer gpa.free(buf);
+ _ = drainWrites(p, buf);
+ term_pane.enterTty(p, 0);
+
+ // Bigger than `effect_cap * 64` (256 KiB), which is where the ring stops
+ // taking chunks: the tail used to be refused and the program saw 256 KiB of
+ // a 300 KiB paste with nothing said. Position-dependent bytes, so a
+ // reordered or duplicated chunk fails as loudly as a missing one.
+ const text = try gpa.alloc(u8, 300 * 1024);
+ defer gpa.free(text);
+ for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26));
+ p.update(.{ .paste = text });
+ try std.testing.expectEqualSlices(u8, text, drainWrites(p, buf));
+ // ...and the core is not still holding a copy afterwards
+ try std.testing.expectEqual(@as(usize, 0), p.pending_write_bytes);
+}
+
+test "a bracketed paste larger than the ring still closes its bracket" {
+ if (platform == .web) return;
+ if (comptime !term_pane.enabled) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const buf = try gpa.alloc(u8, 1 << 20);
+ defer gpa.free(buf);
+ _ = drainWrites(p, buf);
+ term_pane.enterTty(p, 0);
+
+ // The program asks for brackets, so `typeToTty` emits marker, text, marker.
+ // The CLOSING one is queued last and was therefore the first casualty of a
+ // full ring: the program stayed in paste mode and read every later
+ // keystroke as pasted text. Worse than losing the bytes.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
+ try std.testing.expect(term_pane.bracketedPaste(p.panes[0].?));
+ const text = try gpa.alloc(u8, 300 * 1024);
+ defer gpa.free(text);
+ @memset(text, 'z');
+ p.update(.{ .paste = text });
+ const got = drainWrites(p, buf);
+ try std.testing.expect(std.mem.startsWith(u8, got, "\x1b[200~"));
+ try std.testing.expect(std.mem.endsWith(u8, got, "\x1b[201~"));
+ try std.testing.expectEqualSlices(u8, text, got["\x1b[200~".len .. got.len - "\x1b[201~".len]);
+}
+
+test "pasted bytes still queued at shutdown are freed, not leaked" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ const buf = try gpa.alloc(u8, 1 << 20);
+ defer gpa.free(buf);
+ _ = drainWrites(p, buf);
+ term_pane.enterTty(p, 0);
+
+ const text = try gpa.alloc(u8, 300 * 1024);
+ defer gpa.free(text);
+ @memset(text, 'q');
+ p.update(.{ .paste = text });
+ // Parked and deliberately NOT drained — a session killed mid-paste. The
+ // copy is the core's, so `std.testing.allocator` fails this test through
+ // the `deinit` above if shutdown forgets it.
+ try std.testing.expect(p.pending_write_bytes > 0);
+}
+
test "leaving tty hides the prompt and keeps the command typed at it" {
if (platform == .web) return;
const gpa = std.testing.allocator;
diff --git a/src/pdf_pane_integration_test.zig b/src/pdf_pane_integration_test.zig
index 2e671f49..f0a8be8f 100644
--- a/src/pdf_pane_integration_test.zig
+++ b/src/pdf_pane_integration_test.zig
@@ -1775,3 +1775,45 @@ test "PDF native mouse selection, Look, and highlights share page geometry" {
try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len);
try std.testing.expectEqualStrings(saved_query, pv.search_query);
}
+
+test "Esc back into a PDF keeps the offset within its page" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 28 } });
+ const doc = p.active;
+ const dp = p.panes[doc].?;
+ try std.testing.expect(dp.pdf != null);
+ pardes.pdf_test.sync(p);
+
+ // Read a little way DOWN the page you are on, then step away.
+ const pv = &dp.pdf.?;
+ pv.document_scroll_y += 137;
+ const mid = pv.document_scroll_y;
+ pv.scroll_to_page_pending = false;
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ pardes.pdf_test.sync(p);
+ const shell = p.active;
+ try std.testing.expect(shell != doc);
+
+ p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF
+ pardes.pdf_test.sync(p);
+ try std.testing.expectEqual(doc, p.active);
+
+ // The jumps stack records a PDF as its PAGE, so returning revealed the page
+ // you were already on — and a reveal sets `document_scroll_y` to that page's
+ // start, throwing away where you had read to inside it.
+ try std.testing.expectEqual(mid, pv.document_scroll_y);
+ // Same reveal, the other half: with no valid layout yet it only ARMS the
+ // snap, so a test that watched the offset alone would not see it coming.
+ try std.testing.expect(!pv.scroll_to_page_pending);
+}
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 624a68a1..75229e4e 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -787,15 +787,45 @@ fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
var col: usize = 0;
while (col < cols and at < raw.len) {
const cell = &cells[col];
- var cps: usize = 1;
- if (pin.grapheme(cell)) |extra| cps += extra.len;
- for (0..cps) |_| at = modal.nextGrapheme(raw, at);
+ // Step the dump by exactly what THIS CELL contributed to it. The
+ // tempting walk — one `modal.nextGrapheme` per cell — assumes the two
+ // sides agree on where a cluster ends, and they do not: ghostty keeps a
+ // ZWJ family emoji in three cells and spells each one separately, while
+ // pardes' iterator joins the whole sequence into one grapheme. That walk
+ // then consumed three graphemes for one cell's worth of bytes and ate
+ // the first characters of what was typed at the prompt.
+ at = @min(raw.len, at + dumpedBytes(pin, cell));
// the tail cell of a wide glyph spells nothing of its own
col += if (cell.wide == .wide) @as(usize, 2) else 1;
}
return std.mem.trimEnd(u8, raw[at..], " \t");
}
+/// How many bytes `cell` contributed to `pin`'s dumped row.
+///
+/// `ScreenFormatter` writes a cell's codepoint followed by the grapheme
+/// codepoints stored with it, and writes NOTHING for either spacer, so this is
+/// the dump's own arithmetic rather than a guess about clustering.
+fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize {
+ switch (cell.wide) {
+ .spacer_head, .spacer_tail => return 0,
+ .narrow, .wide => {},
+ }
+ var n: usize = switch (cell.content_tag) {
+ .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength(
+ cell.codepoint(),
+ ) catch 1,
+ // A cell carrying only a colour still spells one blank in the dump.
+ else => 1,
+ };
+ if (cell.content_tag == .codepoint_grapheme) {
+ if (pin.grapheme(cell)) |extra| for (extra) |cp| {
+ n += std.unicode.utf8CodepointSequenceLength(cp) catch 1;
+ };
+ }
+ return n;
+}
+
/// A terminal's shell rows as the surface sees them: the WHOLE
/// history+active grid, prompt rows blanked (OSC 133), absolute grid rows
/// from 0. The raw material the motion surface is composed from — the
@@ -1086,7 +1116,25 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
// buffer's and is shared. With no emulator the viewport is simply empty,
// and `fillBody` renders the overlay against blank rows.
const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
- const raw = try pane.vt.plainString(arena);
+ const screen = pane.vt.screens.active;
+ // The dump has to start at COLUMN ZERO of the viewport's first row.
+ // `Terminal.plainString` cannot: it goes through `getTopLeft(.viewport)`,
+ // which hands back the viewport pin verbatim, x and all, while
+ // `PageList.pin` — how the colour pass finds that same row — forces x to
+ // 0. Reflow can leave a tracked viewport pin in the MIDDLE of a row
+ // (narrow the pane until a line wraps, scroll back onto the
+ // continuation, widen it again): from then on this pass dumped row 0
+ // from that column while the colour pass paired the fragment with the
+ // row's first cells, so the row lost its left half and wore the wrong
+ // colours — every frame, until the pane snapped back to live output.
+ // Ghostty's own renderer walks rows and ignores that x, so column zero
+ // is also what the terminal itself draws.
+ var tl = screen.pages.getTopLeft(.viewport);
+ tl.x = 0;
+ const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint;
+ var rows_out: std.Io.Writer.Allocating = .init(arena);
+ try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false });
+ const raw = try rows_out.toOwnedSlice();
var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1);
var lines = std.mem.splitScalar(u8, raw, '\n');
@@ -1112,56 +1160,317 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
return out;
}
+/// Where ONE body row's content comes from. The text pass copies bytes for it
+/// and the colour pass projects the emulator's styles onto it, so handing both
+/// the same answer is what keeps a colour on the row its text landed on.
+pub const BodyRow = union(enum) {
+ /// A shell row, as a VIEWPORT index. Out-of-range values are yielded rather
+ /// than filtered: each consumer knows its own bound (the text pass has the
+ /// dumped rows, the colour pass has the live viewport) and a row nobody can
+ /// source is a blank row, not a skipped one.
+ grid: i32,
+ /// One line of the edit buffer, and WHICH line it is. A line the user never
+ /// changed still stands over the shell row it was seeded from, so the index
+ /// is what lets the colour pass find that row again (see `EditAnchors`).
+ edit: struct { line: []const u8, idx: usize },
+};
+
+/// THE body row walk, shared. Both passes stepping the same iterator is what
+/// makes them agree by CONSTRUCTION rather than by two copies of the same
+/// arithmetic agreeing: `Pane.gridRow` and this walk disagree whenever
+/// `modal.lineCount` and `splitScalar` disagree about how many rows a buffer
+/// occupies (they do, for empty text: 0 against 1), and re-deriving a row's
+/// anchor from `gridRow` per row instead of stepping it here put colours one
+/// row off below an emptied edit buffer.
+const BodyWalk = struct {
+ pane: *Pane,
+ goff: i32,
+ g: i32,
+ /// the buffer can start above the viewport: drop the lines scrolled past
+ skip: usize,
+ n: usize = 0,
+ lines: ?std.mem.SplitIterator(u8, .scalar) = null,
+ covered: i32 = 0,
+ line_idx: usize = 0,
+
+ fn init(pane: *Pane) BodyWalk {
+ const off = pane.scroll();
+ const goff = gridOffset(pane);
+ return .{
+ .pane = pane,
+ .goff = goff,
+ // tty mode does not apply the edit buffer, so it must not be moved
+ // by one either. `Pane.gridRow` and `Pane.surfRow` are NOT inverses
+ // for a row strictly inside the buffer's covered span (surfRow
+ // clamps to the buffer's last line, gridRow collapses the whole
+ // span onto its first shell row), so a buffer left behind by
+ // `enterTty` — which clears every other modal remnant but not this
+ // one — straddling the viewport top used to start this walk ABOVE
+ // the viewport and slide the entire body down.
+ .g = if (pane.mode == .tty) goff else pane.gridRow(off),
+ .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0,
+ };
+ }
+
+ fn next(w: *BodyWalk) ?BodyRow {
+ while (w.n < w.pane.rows) {
+ if (w.lines) |*it| {
+ if (it.next()) |line| {
+ const idx = w.line_idx;
+ w.line_idx += 1;
+ // Lines scrolled off the top still count: the index names a
+ // line of the BUFFER, not of the visible body.
+ if (w.skip > 0) {
+ w.skip -= 1;
+ continue;
+ }
+ w.n += 1;
+ return .{ .edit = .{ .line = line, .idx = idx } };
+ }
+ // The buffer stands in for `rows` shell rows however many lines
+ // it actually spelled, which is the whole slide.
+ w.g += w.covered;
+ w.skip = 0;
+ w.lines = null;
+ continue;
+ }
+ if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) {
+ w.lines = std.mem.splitScalar(u8, o.text, '\n');
+ w.covered = o.rows;
+ w.line_idx = 0;
+ continue;
+ };
+ const vi = w.g - w.goff;
+ w.g += 1;
+ w.n += 1;
+ return .{ .grid = vi };
+ }
+ return null;
+ }
+};
+
/// Run the terminal body row walk. A null destination counts bytes; a slice
/// fills the exact allocation made from that count.
fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
- const goff: i32 = gridOffset(pane);
- const off = pane.scroll();
- var g: i32 = pane.gridRow(off);
- // the buffer can start above the viewport: drop the lines scrolled past
- var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0;
+ var walk: BodyWalk = .init(pane);
var written: usize = 0;
- var n: usize = 0;
- while (n < pane.rows) {
- if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) {
- var bit = std.mem.splitScalar(u8, o.text, '\n');
- var k: usize = 0;
- while (bit.next()) |ln| : (k += 1) {
- if (k < skip) continue;
- if (n >= pane.rows) break;
- if (n > 0) {
- if (dst) |out| out[written] = '\n';
- written += 1;
- }
- if (dst) |out| @memcpy(out[written..][0..ln.len], ln);
- written += ln.len;
- n += 1;
- }
- skip = 0;
- g += o.rows;
- continue;
- };
- if (n > 0) {
+ var first = true;
+ while (walk.next()) |row| {
+ if (!first) {
if (dst) |out| out[written] = '\n';
written += 1;
}
- const vi = g - goff;
- if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) {
- const line = viewport[@intCast(vi)];
- if (dst) |out| @memcpy(out[written..][0..line.len], line);
- written += line.len;
- }
- n += 1;
- g += 1;
+ first = false;
+ const bytes = switch (row) {
+ .edit => |e| e.line,
+ .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len)
+ viewport[@intCast(vi)]
+ else
+ "",
+ };
+ if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes);
+ written += bytes.len;
}
return written;
}
+/// WHICH edit-buffer lines still stand over a shell row.
+///
+/// The buffer only ever GROWS: it starts at the row first typed on and stretches
+/// to cover every row an edit since has touched, so after a few edits it spans
+/// rows the user never altered. Those lines are still byte-identical to the
+/// shell rows they were seeded from, and their anchor is therefore still known —
+/// so they keep their colours, and only lines that actually differ go plain.
+///
+/// The buffer's text is DERIVED from the rows it covers, so the untouched lines
+/// appear in the same ORDER as the rows they came from. The answer is therefore
+/// a MONOTONE MATCHING, and that is what this streams: one shell-row cursor
+/// which only ever moves forward, advanced once per buffer line. A line claims
+/// the first row at or after the cursor that its bytes equal; matching bytes is
+/// the whole proof. A line that matches nothing was typed by the user, so it
+/// claims no row and leaves the rows beneath it to the lines below.
+///
+/// Two ALIGNED guesses — the Nth line over the Nth covered row, and the same
+/// counted from the bottom — are not enough, and the counterexample is one
+/// keystroke. Join two rows (backspace at column 0): the buffer loses a line
+/// and gains covered rows, the two counts cancel at `lines == covered`, and both
+/// guesses resolve to the SAME row, one short of where the lines below actually
+/// live. Every untouched row under the join went plain. Nor is a leading and a
+/// trailing RUN enough: a run stops at the first divergence, so two separate
+/// edits drained the colour of every untouched line BETWEEN them.
+///
+/// Cost is linear in the buffer, which the quadratic version this replaced was
+/// not (walking to the Nth line per line: 35 ms a frame at a few thousand
+/// lines). Every successful claim moves the cursor, so all of them together
+/// scan the covered span once; only a typed line can scan without moving it,
+/// and `budget` is what stops a buffer full of typed lines from paying that
+/// scan per line. Exhausting it costs colour on rows further down, never
+/// correctness.
+const EditAnchors = struct {
+ /// the buffer's own text, walked in order: a line the VIEWPORT skipped still
+ /// consumes the row it came from, so the lines below it stay aligned
+ text: []const u8 = &.{},
+ at: usize = 0,
+ shell: []const []const u8 = &.{},
+ /// the covered span, absolute grid rows, as `[first, end)`
+ first: usize = 0,
+ end: usize = 0,
+ lines: usize = 0,
+ /// the line `at` names, and the first row still unclaimed
+ idx: usize = 0,
+ cursor: usize = 0,
+ budget: usize = 0,
+ active: bool = false,
+
+ fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors {
+ if (o.rows <= 0 or o.row < 0) return .{};
+ const shell = shellRows(p, pane) catch return .{};
+ const first: usize = @intCast(o.row);
+ if (first >= shell.len) return .{};
+ const covered: usize = @intCast(o.rows);
+ const lines = std.mem.count(u8, o.text, "\n") + 1;
+ return .{
+ .text = o.text,
+ .shell = shell,
+ .first = first,
+ .end = @min(first + covered, shell.len),
+ .lines = lines,
+ .cursor = first,
+ .budget = covered + 4 * lines,
+ .active = true,
+ };
+ }
+
+ /// Where buffer line `idx` still stands over the grid, if anywhere. `idx`
+ /// only ever grows — both passes step `BodyWalk` from the top — so catching
+ /// up to it is amortised O(1) per visible row.
+ fn shellRow(a: *EditAnchors, idx: usize) ?Anchor {
+ if (!a.active or idx >= a.lines) return null;
+ var found: ?Anchor = null;
+ while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null);
+ return found;
+ }
+
+ fn nextLine(a: *EditAnchors) ?[]const u8 {
+ if (a.at > a.text.len) return null;
+ const rest = a.text[a.at..];
+ if (std.mem.indexOfScalar(u8, rest, '\n')) |n| {
+ a.at += n + 1;
+ return rest[0..n];
+ }
+ // The last line has no terminator; one past the end ends the walk.
+ a.at = a.text.len + 1;
+ return rest;
+ }
+
+ /// Where this line still stands over the grid, if anywhere.
+ fn claim(a: *EditAnchors, line: []const u8) ?Anchor {
+ // An EXACT row is the best evidence there is, so look for one first and
+ // look anywhere ahead: a line that merely RESEMBLES the row alignment
+ // offers is often the row two below, unchanged and unedited.
+ //
+ // Scanning past the cursor crosses rows that were deleted or joined
+ // away, and the line's bytes are what justify the crossing — so an
+ // EMPTY line may not do it. Empty is not evidence: it equals every
+ // blank row in the span, and splitting a row makes exactly that. Two
+ // keystrokes (Home, Enter) used to hand the blank row below the last
+ // output to the new empty line and take every coloured row in between
+ // out of reach of the lines that owned them.
+ const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end;
+ var k = a.cursor;
+ while (k < end) : (k += 1) {
+ if (a.budget == 0) return null;
+ a.budget -= 1;
+ if (!std.mem.eql(u8, line, a.shell[k])) continue;
+ a.cursor = k + 1;
+ return .{ .row = @intCast(k) };
+ }
+ // No row spells this line, so it is either the row the alignment offers
+ // WITH AN EDIT IN IT, or text typed from nothing. The bytes shared at
+ // the two ends decide which — and, when it is an edit, exactly how much
+ // of the row's colour the line still has a right to.
+ if (a.cursor >= a.end) return null;
+ const shell = a.shell[a.cursor];
+ var p: usize = 0;
+ while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1;
+ var s: usize = 0;
+ const room = @min(line.len, shell.len) - p;
+ while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1;
+ if (p + s == 0) return null;
+ // Accept when the row accounts for the whole LINE (nothing was typed;
+ // the line is a piece of the row, which is the top half of a split), or
+ // when most of the ROW survived in it (an ordinary edit). Otherwise this
+ // is new text that happens to share an edge with its neighbour, and
+ // colouring it would hand it a colour that was never its own.
+ if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null;
+ const row = a.cursor;
+ // A line that stopped short of the row's END leaves the rest of that row
+ // to the NEXT line. Splitting a row in two is exactly that, and it is
+ // why the bottom half can still find its colours: they are in the tail
+ // of the row the top half only partly covered.
+ if (s > 0 or p >= shell.len) a.cursor += 1;
+ return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len };
+ }
+};
+
+/// WHERE a body row's colours come from, and HOW MUCH of the row they cover.
+///
+/// A row whose text is the grid's own takes the grid's colours end to end. A
+/// row the user has EDITED still holds the row's own bytes at its two ends —
+/// they are the same bytes, provably — and those keep their colours; only what
+/// was typed between them has no cell under it and so takes none. Dropping the
+/// whole row instead was the loudest colour bug in the editor: one keystroke
+/// that changed one character's case turned every column of a coloured row
+/// grey.
+const Anchor = struct {
+ /// the row, absolute while it comes from `EditAnchors`, viewport once
+ /// `recolorAnsi` has subtracted the walk's offset
+ row: i32,
+ /// bytes at the START of the line that are still the row's own, and bytes at
+ /// its END. The default says ALL of it: an exact match, or a `.grid` row,
+ /// which is the grid's text by construction.
+ prefix: usize = std.math.maxInt(usize),
+ suffix: usize = 0,
+ /// the row's own dumped length — what the suffix is measured from on the
+ /// GRID side, where the edit may have changed the byte count
+ shell_len: usize = 0,
+
+ fn whole(an: Anchor) bool {
+ return an.prefix == std.math.maxInt(usize);
+ }
+};
+
/// tty colors: recolor each visible body cell from the emulator's own style so
-/// raw output keeps its ansi colors. Runs ONLY in tty mode (the caller gates
-/// it) and reads the live viewport row for row: normal/insert editing shows
-/// plain text, so nothing an edit does can move a shell row's colour.
-pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+/// raw output keeps its ansi colors — in EVERY mode, not just `.tty`, because a
+/// body row's colour has the same origin its text does and `BodyWalk` already
+/// knows it.
+///
+/// Editing moves shell rows around: the edit buffer's lines stand in for the
+/// rows it covers, so everything below slides, and `promptRow` left-hugs a
+/// prompt row so what was typed starts at column 0. A colour therefore needs
+/// exactly two translations, and takes each from the pass that made it:
+///
+/// * ROW — step `BodyWalk`, the same iterator `fillBody` steps. A `.grid` row
+/// names the viewport row whose bytes were drawn; an `.edit` row is the
+/// user's own text with no shell row underneath, so it keeps the body style.
+/// Sharing the walk is load-bearing: deriving the anchor independently (from
+/// `Pane.gridRow`) put colours one row off wherever that arithmetic and this
+/// walk disagreed about a buffer's height.
+/// * COLUMN — pair the PRINTED graphemes with the grid cells that spelled them,
+/// starting at the cell `promptCut` says the hug dropped to. Not `cut + c`:
+/// the two sides disagree about how many columns a cluster is worth (ghostty
+/// splits `👨‍👩‍👧` across three wide cells and spells it once; this surface
+/// prints that one grapheme two columns wide), so column arithmetic walks off
+/// the glyph it means and every cell after it wears a neighbour's colour.
+/// `body` is the very text the caller just printed, which is what makes the
+/// pairing exact rather than a second guess at clustering.
+///
+/// In tty mode the buffer is not applied and no prompt is hugged, so the row
+/// anchor collapses to the viewport row. That is not quite "as it always did":
+/// the walk starts at `Pane.gridRow(pane.scroll())` like `fillBody`, so where a
+/// stale buffer skews that start, the colours now follow the text instead of
+/// silently disagreeing with it.
+pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
// No emulator, no ANSI cells: the whole pass — and the 256-colour theme
// projection behind it — is compiled out.
if (comptime !enabled) return;
@@ -1172,29 +1481,215 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
filtered_storage = FilteredColors.init(p, pane);
break :blk &filtered_storage;
} else null;
+ // DECSCNM, read once: the filtered palette folds it in itself, the raw
+ // path needs it per cell.
+ const scnm = pane.vt.modes.get(.reverse_colors);
+ const pages = &pane.vt.screens.active.pages;
+ // The text pass bounds its rows by the dump it was handed; this one has the
+ // live viewport, so it bounds by the viewport's own height. Both bounds
+ // exist for the same reason and NEITHER is `pin`: `PageList.pin` resolves a
+ // viewport row by walking DOWN the pagelist, so a viewport scrolled back
+ // answers happily for rows below its bottom edge — which painted the
+ // scrollback's colours onto rows the text pass had left blank.
+ const vp_rows: i32 = @intCast(scrollbar(pane).len);
+ // Which buffer lines the user has not actually changed, so a row swallowed
+ // by a growing buffer keeps the colour it still stands over.
+ var anchors: EditAnchors = if (pane.mode != .tty)
+ if (pane.ovl) |o| .init(p, pane, o) else .{}
+ else
+ .{};
+ var walk: BodyWalk = .init(pane);
+ // The printed body, one line per body row, stepped ONCE per row alongside
+ // the walk. Asking for the Nth line per row instead re-scanned the whole
+ // body every time, which made a tall pane's render superlinear.
+ var lines = std.mem.splitScalar(u8, body, '\n');
var vr: u16 = 0;
- while (vr < body_h and vr < pane.rows) : (vr += 1) {
- var c: u16 = 0;
- while (c < tw) : (c += 1) {
- const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
- // Ghostty gives a wide glyph's spacer tail the head's style id;
- // ordinary projection leaves it, a filter must repaint it too.
- if (ci.cell.wide == .spacer_tail and filtered == null) continue;
- const cell = s.at(tx + c, body_y + vr);
- // sparse projection: bodyText already painted every glyph, so only
- // a filter (which theme-keys blank/default cells too) touches these.
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = cellStyle(p, ci, filtered);
+ while (walk.next()) |row| : (vr += 1) {
+ if (vr >= body_h) break;
+ // Before any early exit below, or the lines fall out of step with rows.
+ const text = lines.next() orelse "";
+ const anchor: Anchor = switch (row) {
+ // A line the user typed from nothing has no cell under it; one they
+ // only had swallowed, or edited a piece of, still names the row its
+ // bytes came from and how much of it is still that row's.
+ .edit => |e| blk: {
+ var an = anchors.shellRow(e.idx) orelse continue;
+ an.row -= walk.goff;
+ break :blk an;
+ },
+ .grid => |v| .{ .row = v },
+ };
+ const vi = anchor.row;
+ if (vi < 0 or vi >= vp_rows) continue;
+ const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue;
+ // The prompt the text pass dropped, added back as a starting CELL.
+ // Gated on the ROW FLAG first, exactly as `bodyText` gates `promptRow`:
+ // `promptCut` answers for the whole row under
+ // `config.tty_blank == .prompt_and_input`, so asking it about a row the
+ // text pass never asked about would blank colours nobody hid.
+ const cut: u16 = if (pane.mode == .tty) 0 else cut: {
+ if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0;
+ break :cut switch (promptCut(row_pin)) {
+ .keep => 0,
+ // Blanked end to end: the row shows nothing of the grid, so
+ // projecting the prompt's own colours onto it would be a lie.
+ .blank => continue,
+ .cut => |n| std.math.cast(u16, n) orelse continue,
+ };
+ };
+ // The text this row printed is walked grapheme by grapheme alongside the
+ // cells that spelled it. Both walks are driven by real data — the
+ // printed bytes and the cells' own dumped byte counts — so neither has
+ // to guess how many columns the other gives a cluster.
+ // The hug can empty a row outright: a prompt whose command did not fit
+ // leaves ghostty a styled spacer and nothing printable. The row DRAWS
+ // nothing, so nothing on it may take the grid's colour — the same
+ // reasoning as `.blank` above, reached by a different route.
+ if (cut > 0 and text.len == 0) continue;
+ var at: usize = 0;
+ var sc: u16 = 0;
+ var gc: u16 = cut;
+ // Shell bytes crossed so far, which is how the row's TAIL is found again
+ // after an edit: the printed text and the grid agree byte for byte over
+ // `prefix` and over `suffix`, and nowhere in between.
+ var sb: usize = 0;
+ const mine_from = @min(anchor.prefix, text.len);
+ const mine_to = text.len - @min(anchor.suffix, text.len);
+ var crossed = false;
+ while (at < text.len and sc < tw) {
+ const stop = modal.nextGrapheme(text, at);
+ if (stop <= at) break;
+ // What the glyph occupies HERE: `print` leaves an empty cell under a
+ // double-width one, and `fill` writes a space, so a zero-length cell
+ // is a spacer and nothing else. It is a property of the SURFACE, so
+ // it is known before any cell is consumed — which is what lets the
+ // user's own text spend its columns without spending the row's.
+ //
+ // This rule assumes the printed text holds no `\t` and no `\r`:
+ // `Surface.print` expands a tab into `config.tab_width` cells and
+ // draws nothing at all for a carriage return, either of which would
+ // slide every later colour on the row. The assumption is ghostty's,
+ // not ours — its row dump expands tabs to real spaces and replaces
+ // undecodable bytes with U+FFFD — so it holds for anything sourced
+ // from the grid, and an anchor only ever covers bytes that ARE such
+ // a row's. Feed this text from anywhere else and the span rule is
+ // the thing that breaks first.
+ const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1;
+ // Between the row's own two ends lie the bytes the user typed. No
+ // cell spelled them, so they take no colour and spend no grid
+ // column: the row's tail then still lines up with the line's tail.
+ if (at >= mine_from and at < mine_to) {
+ sc += span;
+ at = stop;
+ continue;
+ }
+ // Crossing back into the row's own bytes: step over the cells whose
+ // bytes the edit replaced. `shell_len - suffix` is where the row's
+ // own tail starts on the GRID side, which is not where it starts in
+ // the line whenever the edit changed the byte count.
+ if (at >= mine_to and !crossed) {
+ crossed = true;
+ const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len);
+ while (sb < upto) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ sb += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ }
+ const want = stop - at;
+ // Consume every cell that contributed to this grapheme. A cluster
+ // ghostty split across several cells is still ONE printed glyph.
+ var covered: usize = 0;
+ var style: ?pardes.CellStyle = null;
+ while (covered < want) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head)
+ style = cellStyle(p, ci, filtered, scnm);
+ covered += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ // A spacer contributes no bytes; without this the loop would
+ // spin on a row that ends in one.
+ if (covered == 0 and gc >= pane.cols) break;
+ }
+ // A wide cell's tail contributes NO bytes, so the loop above stops
+ // on it rather than past it. Step over any tail now: leaving `gc` on
+ // one pairs the next surface column with the cell before it, which
+ // left an unpainted hole beside a row-final CJK glyph and pushed
+ // every colour after it one column right.
+ while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| {
+ if (t.cell.wide != .spacer_tail) break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ if (style) |st| for (0..span) |k| {
+ const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr);
+ // sparse projection: bodyText already painted every glyph, so
+ // only a filter (which theme-keys blank/default cells too)
+ // touches these.
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = st;
+ };
+ sb += covered;
+ sc += span;
+ at = stop;
+ }
+ // Past the text: the row's remaining cells carry colour but no glyph
+ // (an erase-to-end-of-line under a background). One cell, one column
+ // from here, with two exceptions on the grid side.
+ //
+ // Only a row that ENDS in the row's own bytes may ask what lies past
+ // them. Where the user's own text runs to the end of the line, the next
+ // cells still spell bytes the edit removed, and painting the line's
+ // margin from those would dress it in the colours of text that is no
+ // longer there.
+ var tail: ?pardes.CellStyle = null;
+ if (anchor.whole() or anchor.suffix > 0) {
+ while (sc < tw) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ // A TAIL spells nothing and owns no column of its own, so it
+ // moves the grid on without spending a surface column. A HEAD
+ // does own its column — it is the gap ghostty leaves where a
+ // wide glyph would not fit, and it carries the row's background
+ // — so it is painted like any other cell. Skipping it left the
+ // last column of a coloured row bare, because a head is by
+ // construction that row's final cell.
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + sc, body_y + vr);
+ sc += 1;
+ const style = cellStyle(p, ci, filtered, scnm);
+ tail = style;
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ }
+ // The grid can run out before the surface does: a cluster ghostty
+ // spends four cells on may print in two columns here, so a row
+ // ending in one has columns with no cell left to ask. The row's
+ // background does reach its edge on the grid, so carry the last
+ // cell's answer across rather than leaving a notch of pane colour at
+ // the margin.
+ if (tail) |style| while (sc < tw) : (sc += 1) {
+ const cell = s.at(tx + sc, body_y + vr);
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ };
}
}
}
-fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors) pardes.CellStyle {
+/// `scnm` is DECSCNM (`\x1b[?5h`), which swaps only the terminal's DEFAULT
+/// colour roles — explicit SGR colours stay explicit. `FilteredColors` applies
+/// it by swapping the theme's two defaults; the raw path resolves a `.none`
+/// colour through `ghostColor`, whose `is_bg` argument chooses which default it
+/// means, so flipping that argument is the same swap. Without it reverse video
+/// simply vanished whenever `tty_filter` was off.
+fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle {
const style = ci.style();
var cs: pardes.CellStyle = .{
- .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, false),
- .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, true),
+ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm),
+ .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm),
.bold = style.flags.bold,
.dim = style.flags.faint,
.italic = style.flags.italic,
@@ -1534,7 +2029,7 @@ test "terminal Filter preserves exact palette-null light theme default roles" {
try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
}
-test "tty ansi colors render only in tty mode, never in normal mode" {
+test "tty ansi colors follow the prompt hug into normal mode" {
const testing = std.testing;
const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
defer p.deinit();
@@ -1559,15 +2054,777 @@ test "tty ansi colors render only in tty mode, never in normal mode" {
try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg);
try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg);
- // normal mode is a plain editing view: no ansi projection at all, so an
- // edit made here cannot change what tty mode renders.
+ // Normal mode hugs the prompt away, so `R` starts at column 0 — and its
+ // colour comes with it. The two cells the prompt occupied are the COLUMN
+ // anchor `promptCut` hands back, which is the only reason the red lands on
+ // the R the user can see instead of two cells to the right of it.
pane.mode = .normal;
p.shell_rows.stale = true;
_ = frame.reset(.retain_capacity);
const norm = try p.render(frame.allocator());
try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme());
- try testing.expect(!std.meta.eql(red, norm.at(tx, body_y).style.fg));
- try testing.expect(!std.meta.eql(blue, norm.at(tx + 1, body_y).style.fg));
+ try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme());
+ try testing.expectEqual(red, norm.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg);
+}
+
+test "an edit buffer slides shell rows and their colors together" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const green: pardes.Color = .{ .index = 2 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ try testing.expectEqual(red, before.at(tx, body_y).style.fg);
+ try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg);
+
+ // Four lines of typed text standing in for the ONE shell row `AAA` was:
+ // every row below slides down by three, and `surfRow` is the arithmetic
+ // that says so. The colours have to take the same three rows, or `BBB`
+ // would be painted green three rows above where it is now drawn.
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme());
+ try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme());
+ try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg);
+ try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg);
+
+ // ...and the rows the user typed are the user's own text: no shell row
+ // sits under them, so nothing projects a colour onto them.
+ for (0..4) |i| {
+ const cell = after.at(tx, body_y + @as(u16, @intCast(i)));
+ try testing.expect(!std.meta.eql(red, cell.style.fg));
+ try testing.expect(!std.meta.eql(green, cell.style.fg));
+ try testing.expect(!std.meta.eql(blue, cell.style.fg));
+ }
+}
+
+test "a combining mark in the prompt keeps the command and its colors aligned" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC`
+ // typed at it. The cell walk that finds the prompt's end must step ONE
+ // grapheme for that cell, not one per stored codepoint: stepping twice ate
+ // the `A`, and left every colour a cell to the left of its glyph with the
+ // last one stranded on a blank.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+ try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme());
+ try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg);
+ // ...and no colour past the end of what the row actually says
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg));
+}
+
+test "colors are never taken from shell rows below the viewport" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ // Raw palette, so a leaked background reads back as `.index` — the theme
+ // filter would repaint every blank cell and hide the evidence.
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ // Sixty rows, each a distinct background, so a leaked colour names the row
+ // it leaked from.
+ for (0..60) |i| {
+ var buf: [32]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+ scrollGrid(pane, -20);
+
+ // ONE buffer line standing in for SIX shell rows: everything below slides
+ // UP five, so the last rows of the body resolve past the viewport's bottom
+ // edge. `PageList.pin` answers for those rows anyway — it walks down the
+ // pagelist, not the viewport — so without a bound of its own this pass
+ // painted the scrollback's colours onto rows the text pass left blank.
+ const anchor = gridOffset(pane);
+ pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_h = r.h - pardes.BOX_H;
+
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+ // A body row the text pass left blank has no shell row under it, so no
+ // ANSI background may have reached it. Every colour in the payload above is
+ // an indexed one, so a leak is exactly an `.index` background on a blank row.
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var blank = true;
+ var c: u16 = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false;
+ }
+ if (!blank) continue;
+ c = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ const bg = s.at(tx + c, body_y + vr).style.bg;
+ try testing.expect(std.meta.activeTag(bg) != .index);
+ }
+ }
+}
+
+test "a row the edit buffer only swallowed keeps its color" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const green: pardes.Color = .{ .index = 2 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ // The buffer only ever grows, so after a few edits it covers rows nobody
+ // touched. Here it spans all three and only the MIDDLE line differs: the
+ // first and last are still byte-identical to the shell rows they were
+ // seeded from, so they still stand over them and keep their colours.
+ pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme());
+ try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme());
+ try testing.expectEqual(red, s.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg);
+ // ...and the line that actually changed is the user's own text now
+ try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg));
+}
+
+test "an edit buffer reaching past the dumped rows colors nothing from row zero" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+
+ // Covers far more rows than the grid was ever dumped for, so the anchor
+ // table cannot be built and answers "no shell row" for every line. The
+ // zeroed table must not read as "the last line sits on the buffer's first
+ // row", which claimed row zero's colour and underflowed on every line after.
+ pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme());
+ var i: u16 = 1;
+ while (i <= 3) : (i += 1) {
+ try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg));
+ }
+}
+
+/// TTY MODE IS THE ORACLE. It paints the viewport row for row and column for
+/// column, so whatever it shows on a glyph is what that glyph's colour IS.
+/// Normal mode may move a glyph LEFT (the prompt hug) but must never change its
+/// colour, so the comparison aligns by glyph rather than by column: for each
+/// row the shift is recovered by finding where normal mode's glyph run sits in
+/// tty mode's, without asking the code under test what it did.
+///
+/// Returns the number of cells whose style disagrees; `note` labels the report.
+fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize {
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const rows: usize = r.h - pardes.BOX_H;
+ const cols: usize = r.w -| config.GUTTER;
+
+ const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle };
+ const glyphAt = struct {
+ fn f(sn: Snap, i: usize) []const u8 {
+ return sn.text[i][0..sn.len[i]];
+ }
+ }.f;
+ var shot: [2]Snap = undefined;
+ for (&shot) |*sn| {
+ sn.text = try gpa.alloc([7]u8, rows * cols);
+ sn.len = try gpa.alloc(u8, rows * cols);
+ sn.style = try gpa.alloc(pardes.CellStyle, rows * cols);
+ }
+ defer for (&shot) |*sn| {
+ gpa.free(sn.text);
+ gpa.free(sn.len);
+ gpa.free(sn.style);
+ };
+
+ for ([_]pardes.Mode{ .tty, .normal }, 0..) |mode, i| {
+ pane.mode = mode;
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const s = try p.render(frame.allocator());
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ shot[i].text[row * cols + col] = cell.text;
+ shot[i].len[row * cols + col] = cell.len;
+ shot[i].style[row * cols + col] = cell.style;
+ };
+ }
+
+ var diffs: usize = 0;
+ for (0..rows) |row| {
+ const base = row * cols;
+ // The glyph run normal mode shows, and where it ends.
+ var last: ?usize = null;
+ for (0..cols) |col| {
+ if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col;
+ }
+ const end = last orelse continue; // blank row: nothing to align
+
+ // Recover the shift: the first offset at which tty mode spells the same
+ // run. Zero for every row no prompt was hugged out of.
+ const shift = shift: {
+ var s: usize = 0;
+ while (s + end < cols) : (s += 1) {
+ var all = true;
+ for (0..end + 1) |col| {
+ if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) {
+ all = false;
+ break;
+ }
+ }
+ if (all) break :shift s;
+ }
+ var tty_row: [256]u8 = undefined;
+ var nrm_row: [256]u8 = undefined;
+ var tn: usize = 0;
+ var nn: usize = 0;
+ for (0..cols) |col| {
+ const tg = glyphAt(shot[0], base + col);
+ const ng = glyphAt(shot[1], base + col);
+ if (tn + tg.len < tty_row.len) {
+ @memcpy(tty_row[tn..][0..tg.len], tg);
+ tn += tg.len;
+ }
+ if (nn + ng.len < nrm_row.len) {
+ @memcpy(nrm_row[nn..][0..ng.len], ng);
+ nn += ng.len;
+ }
+ }
+ std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] });
+ diffs += 1;
+ break :shift null;
+ } orelse continue;
+
+ // Every column the shift can reach, not just the ones holding a glyph:
+ // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded
+ // table cell) carries colour too, and is exactly what a shell paints
+ // most of.
+ for (0..cols - shift) |col| {
+ const want = shot[0].style[base + col + shift];
+ const got = shot[1].style[base + col];
+ if (std.meta.eql(want, got)) continue;
+ if (diffs < 6) std.debug.print(
+ "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}",
+ .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul },
+ );
+ diffs += 1;
+ }
+ }
+ if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs });
+ return diffs;
+}
+
+test "a prompted session keeps every glyph's color in normal mode" {
+ const testing = std.testing;
+ const payload =
+ "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++
+ "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++
+ "\x1b[31merror: nope\x1b[0m\r\n" ++
+ "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++
+ "\x1b[38;5;208mhi\x1b[0m\r\n";
+
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ p.update(.{ .output = .{ .pane = 0, .bytes = payload } });
+ const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off");
+ try testing.expectEqual(@as(usize, 0), diffs);
+ }
+}
+
+test "an emoji prompt neither eats the command nor slides its colors" {
+ const testing = std.testing;
+ // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this
+ // surface can BOTH be wrong about a cluster's width, and then a differential
+ // agrees with itself while the user sees the wrong thing. What is typed at
+ // the prompt is what must appear, each character wearing its own colour.
+ //
+ // Ghostty splits these clusters across cells and spells each one in the row
+ // dump, so the cell walk and the byte walk only agree if the byte walk is
+ // driven by what each CELL contributed. `👨‍💻` is two wide cells, `👨‍👩‍👧`
+ // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here.
+ const prompts = [_][]const u8{
+ "plain",
+ "\u{1F468}\u{200D}\u{1F4BB}", // technologist
+ "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family
+ "\u{1F1FA}\u{1F1F8}", // flag
+ "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone
+ "\u{2764}\u{FE0F}", // heart, VS16
+ "\u{0031}\u{FE0F}\u{20E3}", // keycap
+ "\u{754C}", // CJK wide
+ "e\u{301}", // NFD
+ };
+ for (prompts) |prompt| {
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = .normal;
+
+ var buf: [256]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(
+ &buf,
+ "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m",
+ .{prompt},
+ );
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| {
+ const cell = s.at(tx + @as(u16, @intCast(i)), body_y);
+ testing.expectEqualStrings(want, cell.grapheme()) catch |err| {
+ std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i });
+ return err;
+ };
+ }
+ // `ab` was printed red and `cd` blue, so whatever the theme does
+ // with those two runs, the pair boundary has to fall between `b`
+ // and `c`. A prompt that cost the row a character shows up here as
+ // the boundary sliding onto the wrong glyph.
+ const fg = [_]pardes.Color{
+ s.at(tx, body_y).style.fg,
+ s.at(tx + 1, body_y).style.fg,
+ s.at(tx + 2, body_y).style.fg,
+ s.at(tx + 3, body_y).style.fg,
+ };
+ errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg });
+ try testing.expect(std.meta.eql(fg[0], fg[1]));
+ try testing.expect(std.meta.eql(fg[2], fg[3]));
+ try testing.expect(!std.meta.eql(fg[1], fg[2]));
+ if (!filter) {
+ try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]);
+ try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]);
+ }
+ }
+ }
+}
+
+test "background-only cells keep their color through the prompt hug" {
+ const testing = std.testing;
+ // Backgrounds with no glyph under them are most of what a shell paints:
+ // erase-to-end-of-line after a colour is set, padded table cells, and a
+ // selected row. They have no text to align on, so they are the cells a
+ // column translation is most likely to lose.
+ const payload =
+ "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++
+ "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++
+ "a\x1b[42m \x1b[0mb\r\n" ++
+ "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++
+ "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++
+ "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n";
+
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ p.update(.{ .output = .{ .pane = 0, .bytes = payload } });
+ const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off");
+ try testing.expectEqual(@as(usize, 0), diffs);
+ }
+}
+
+test "a leftover edit buffer does not move what tty mode shows" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+
+ for (0..60) |i| {
+ var buf: [40]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const rows: usize = r.h - pardes.BOX_H;
+ const cols: usize = r.w -| config.GUTTER;
+
+ // What tty mode shows with nothing left behind: the reference.
+ p.shell_rows.stale = true;
+ const clean = try p.render(frame.allocator());
+ const want_text = try testing.allocator.alloc([7]u8, rows * cols);
+ defer testing.allocator.free(want_text);
+ const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols);
+ defer testing.allocator.free(want_fg);
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ want_text[row * cols + col] = cell.text;
+ want_fg[row * cols + col] = cell.style.fg;
+ };
+
+ // `enterTty` clears every other modal remnant but leaves the edit buffer, so
+ // a buffer whose covered span STRADDLES the viewport top is an ordinary
+ // state. tty mode does not apply the buffer, so it must not be moved by one
+ // either — and `surfRow`/`gridRow` are not inverses across that span.
+ const anchor = gridOffset(pane);
+ pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ try testing.expectEqualStrings(
+ std.mem.sliceTo(&want_text[row * cols + col], 0),
+ std.mem.sliceTo(&cell.text, 0),
+ );
+ try testing.expectEqual(want_fg[row * cols + col], cell.style.fg);
+ };
+}
+
+test "a background after a row-final wide glyph lands on the right columns" {
+ const testing = std.testing;
+ // A CJK glyph then a coloured erase-to-end-of-line, with a second colour
+ // partway. The glyph's grid tail spells no bytes, so the pairing walk used
+ // to stop ON it and pair every later column with the cell before it: an
+ // unpainted hole beside the glyph and every boundary one column right.
+ //
+ // ABSOLUTE assertions: both modes were wrong identically here, so a
+ // tty/normal differential says nothing.
+ for ([_]bool{ false, true }) |filter| {
+ for ([_]pardes.Mode{ .tty, .normal }) |mode| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = mode;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme());
+ // The glyph covers columns 0-1; red runs from 2 up to the second
+ // erase at column 6 (1-based 7), blue from there to the edge.
+ const red = s.at(tx + 3, body_y).style.bg;
+ const blue = s.at(tx + 9, body_y).style.bg;
+ errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg });
+ try testing.expect(!std.meta.eql(red, blue));
+ for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg);
+ for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg);
+ }
+ }
+}
+
+test "a colored row reaches its last column when a wide glyph did not fit" {
+ const testing = std.testing;
+ // Thirteen cells of red background, then a wide glyph with one column left:
+ // ghostty leaves a `spacer_head` in that last column, carrying the row's
+ // background, and wraps the glyph to the next row. A head OWNS its column,
+ // so skipping it the way a tail is skipped left the row's final column bare.
+ for ([_]pardes.Mode{ .tty, .normal }) |mode| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = mode;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ const red: pardes.Color = .{ .index = 1 };
+ var c: u16 = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg });
+ try testing.expectEqual(red, s.at(tx + c, body_y).style.bg);
+ }
+ }
+}
+
+test "tty colours survive a scrollback deeper than the pane" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+ for (0..40) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const s = try p.render(frame.allocator());
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ var bad: usize = 0;
+ for (0..r.h -| pardes.BOX_H) |vr| {
+ var buf: [16]u8 = undefined;
+ var n: usize = 0;
+ for (0..10) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ if (g.len != 1) break;
+ buf[n] = g[0];
+ n += 1;
+ }
+ const txt = buf[0..n];
+ if (!std.mem.startsWith(u8, txt, "line-")) continue;
+ const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue;
+ const want = pardes.Color{ .index = @intCast(20 + num) };
+ const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg;
+ if (!std.meta.eql(want, got)) {
+ bad += 1;
+ std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got });
+ }
+ }
+ try testing.expectEqual(@as(usize, 0), bad);
+}
+
+test "reverse video swaps the default colors with the filter off too" {
+ const testing = std.testing;
+ // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to
+ // be honoured on BOTH colour paths. The theme filter folds it into its own
+ // palette; the raw path resolves a `.none` colour by role, and simply
+ // dropped reverse video altogether.
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = .normal;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } });
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ const plain = before.at(tx, body_y).style;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+ const reversed = after.at(tx, body_y).style;
+
+ errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg });
+ try testing.expectEqual(plain.fg, reversed.bg);
+ try testing.expectEqual(plain.bg, reversed.fg);
+ }
+}
+
+test "untouched lines between two edits keep their colors" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ for (0..6) |i| {
+ var buf: [40]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ // The state two ordinary edits reach: one at the bottom, one that split a
+ // line further up. The buffer now spans rows 2..6 and diverges at BOTH
+ // ends, with three untouched lines in the middle. Matching a leading and a
+ // trailing run stops at the first divergence and drains exactly those three;
+ // each line carries its own evidence, so each is anchored on its own.
+ pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05`
+ for (0..3) |k| {
+ const vr = @as(u16, @intCast(4 + k));
+ var buf: [8]u8 = undefined;
+ const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable;
+ const cell = s.at(tx, body_y + vr);
+ errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg });
+ try testing.expectEqualStrings(want_text[0..1], cell.grapheme());
+ try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg);
+ }
+}
+
+test "a prompt row hidden end to end paints nothing at all" {
+ const testing = std.testing;
+ // The command's first glyph is wide with one column left, so ghostty leaves
+ // a spacer_head carrying the command's background and wraps the glyph to
+ // the next row. `promptRow` renders this row EMPTY, so no cell of it may
+ // take a colour — a spacer owns no column of its own.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme());
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg));
+}
+
+test "an emptied edit buffer does not shift the colors below it" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ // The state three keystrokes reach on any blank shell row: type a character
+ // and delete it, and the buffer holds NO text while still standing in for
+ // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one
+ // line, so anything deriving the slide from the former puts every colour
+ // below here one row too far down — and drops the bottom row's entirely.
+ pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme());
+ try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg);
+ // ...and the user's own empty line takes no colour from the row beneath it
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg));
}
test "an edit overlay never changes tty-mode ansi colors" {
@@ -1716,3 +2973,252 @@ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
return .{};
}
+
+test "an edited row keeps the colours of the bytes the edit did not touch" {
+ const testing = std.testing;
+ // The loudest colour bug this editor had: one keystroke anywhere in a
+ // coloured row turned EVERY column of it grey, because an anchor was all or
+ // nothing. The row's own bytes survive at both ends of what was typed, and
+ // being the same bytes they keep the same colours; only the typed character
+ // has no cell under it and so takes none.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..6) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ // One `Z` typed into the middle of row 3's own text.
+ pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ const want = pardes.Color{ .index = 33 };
+ var seen = false;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [15]u8 = undefined;
+ for (0..15) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue;
+ seen = true;
+ for (0..15) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got });
+ // Column 9 is the typed `Z`; every other column is row 3's own.
+ if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got);
+ }
+ }
+ try testing.expect(seen);
+}
+
+test "joining two rows leaves the rows below them their colours" {
+ const testing = std.testing;
+ // A join removes a buffer line while the buffer's covered span GROWS, so the
+ // two counts cancel at `lines == covered`. Anchoring that only counts down
+ // from the buffer's top and up from its bottom then resolves both ways to
+ // the SAME row, one short of where the lines below live, and every untouched
+ // row under the join went plain. This is the state four keystrokes reach
+ // (Enter, then a backspace two rows up), taken from the fuzzer that found it.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ pane.ovl = .{
+ .row = 23,
+ .rows = 4,
+ .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"),
+ };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ var seen = false;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [12]u8 = undefined;
+ for (0..12) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue;
+ seen = true;
+ // The join is above it and its own text is untouched, so every column
+ // still carries row 25's own colour.
+ for (0..12) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got });
+ try testing.expectEqual(pardes.Color{ .index = 45 }, got);
+ }
+ }
+ try testing.expect(seen);
+}
+
+test "an untouched row always carries the colour its own text names" {
+ const testing = std.testing;
+ // Random editing, absolute oracle: every row's own text names the colour it
+ // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing
+ // anything else. This is what found the join above, and the empty line that
+ // claimed a blank row far below it and took every coloured row in between
+ // out of reach of the lines that owned them.
+ var seed: u64 = 0;
+ while (seed < 40) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const rand = prng.random();
+
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ // The raw palette, so a row's text names its exact colour instead of one
+ // this test would have to re-derive from the theme.
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_h = r.h -| pardes.BOX_H;
+
+ var step: usize = 0;
+ while (step < 12) : (step += 1) {
+ _ = frame.reset(.retain_capacity);
+ const s = try p.render(frame.allocator());
+ for (0..body_h) |vr| {
+ var buf: [24]u8 = undefined;
+ for (0..24) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ const txt = std.mem.trimEnd(u8, buf[0..24], " ");
+ if (txt.len != 12) continue;
+ if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue;
+ const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue;
+ const want = pardes.Color{ .index = @intCast(20 + num) };
+ for (0..txt.len) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got });
+ try testing.expectEqual(want, got);
+ }
+ }
+
+ switch (rand.intRangeAtMost(u8, 0, 10)) {
+ 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }),
+ 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }),
+ 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }),
+ 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }),
+ 4 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 5 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.enter } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 6 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.backspace } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 7 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = 'W', .text = "W" } });
+ p.update(.{ .key = .{ .cp = 'W', .text = "W" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }),
+ 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }),
+ else => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.delete } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ }
+ while (p.nextEffect()) |_| {}
+ }
+ }
+}
+
+test "a new empty line does not take the colours of the rows below it" {
+ const testing = std.testing;
+ // Splitting a row makes an EMPTY buffer line, and empty equals every blank
+ // row in the buffer's span - including the one under the last output. Left
+ // free to look ahead for a row spelling the same bytes, that line claimed
+ // the blank row far below and put every coloured row in between out of
+ // reach of the lines that owned them. Two keystrokes (Home, Enter) got here.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ // A newline typed at column 0 of row 24, and `WW` typed on the blank row
+ // below the output: the span covers rows 24, 25 and that blank row.
+ pane.ovl = .{
+ .row = 24,
+ .rows = 3,
+ .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"),
+ };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ var seen: usize = 0;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [12]u8 = undefined;
+ for (0..12) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.startsWith(u8, &buf, "row-2")) continue;
+ const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue;
+ if (num != 24 and num != 25) continue;
+ seen += 1;
+ const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got });
+ try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got);
+ }
+ try testing.expectEqual(@as(usize, 2), seen);
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index f8042d71..d7de3ae2 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -2,6 +2,11 @@
//! events into core events, performs the core's effects (fork ptys, write
//! them, resize them), and hands the core's Surface to vaxis cell-for-cell —
//! the canonical interface rendered with no interpretation.
+//!
+//! It also holds the OTHER loop a terminal can run: an attached frontend,
+//! which has a socket where its core would be and performs no machine-local
+//! effect whatsoever (see `Attach`). The `Attach` builtin turns the first into
+//! the second in place, without giving up the terminal.
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
@@ -21,18 +26,14 @@ const fuse = @import("../fuse.zig");
const fs_service = @import("../fs_service.zig");
const panel_compositor = @import("panel_compositor.zig");
const host_api = @import("../host.zig");
-const config = @import("../config.zig");
-// The other half of `--detach`, and the reason this file has an `--attach`
-// branch at all: the frontend side of a detached session is a terminal and a
-// socket, and this file is already the one that owns a terminal.
+// The other half of `--detach`, and the reason this file has an attached loop
+// at all: the frontend side of a detached session is a terminal and a socket,
+// and this file is already the one that owns a terminal.
const detached_client = @import("../detached/client.zig");
const detached_server = @import("../detached/server.zig");
const wire = @import("../detached/wire.zig");
+const host_io = @import("../host_io.zig");
-extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
-extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
-extern "c" fn chdir(path: [*:0]const u8) c_int;
-extern "c" fn _exit(status: c_int) noreturn;
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize)
@@ -453,8 +454,9 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee
}
/// `attach` is `--attach[=<name>]`: empty means "the session there is" (see
-/// `sessionName`). It is a parameter rather than an `Options` field because it
-/// says nothing to the core — this process does not have one when it is set.
+/// `detached_client.resolve`). It is a parameter rather than an `Options` field
+/// because it says nothing to the core — this process does not have one when
+/// it is set.
pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void {
const io = init.io;
const gpa = init.gpa;
@@ -470,19 +472,27 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
// ...and resolved before the terminal too, for the same reason turned the
// other way: "no session called work" is a launch that never started, and
// flashing the alt screen up and straight back down to say so is worse
- // than never entering it.
+ // than never entering it. Only the QUESTION is asked here, and asked
+ // through client.zig because the SDL frontend asks the identical one:
+ // `detached_client.attempt` asks it again with the socket in hand, and a
+ // session that ends between the two answers is a `.no_session` from there
+ // rather than a disagreement between two spellings of the same scan.
var name_buf: [detached_server.path_max]u8 = undefined;
var attach_name: []const u8 = &.{};
- if (attach) |requested| attach_name = sessionName(&name_buf, requested, &attach_end) orelse return;
-
- const allocs = pardes.allocators.init(gpa);
- defer pardes.allocators.deinit();
- var options = opts;
- options.image_allocator = allocs.image;
- options.pdf_allocator = allocs.pdf;
- options.tree_sitter_allocator = allocs.tree_sitter;
- // the 16 MiB static buffer behind every per-frame Surface
- options.frame_allocator = allocs.frame;
+ if (attach) |requested| switch (detached_client.resolve(&name_buf, requested)) {
+ .name => |resolved| attach_name = resolved,
+ // Two ends for the union's one arm, because the advice differs: a name
+ // that resolved to nothing is a typo to correct, and no name at all is
+ // a session to start.
+ .none => {
+ attach_end = if (requested.len != 0) .{ .no_session = requested } else .nothing_detached;
+ return;
+ },
+ .ambiguous => |found| {
+ attach_end = .{ .ambiguous = found };
+ return;
+ },
+ };
// SIGWINCH must never run vaxis's signal handler: it posts the winsize
// event through std.Io.Mutex/Condition, and when the signal lands on a
@@ -529,10 +539,105 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
// ordinary exit does, and sharing the deferred teardown is the only way to
// guarantee that instead of asserting it.
if (attach != null) {
- attach_end = attachSession(init, opts, attach_name, &tty, &vx);
+ attach_end = attachSession(init, attach_name, &tty, &vx);
return;
}
+ // Everything below is the TERMINAL's, shared by the two loops that can draw
+ // on it: the local session's and, after an `Attach`, an attached one's. The
+ // core and everything that only a core needs is `localSession`'s.
+ var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa);
+ defer {
+ clearNativeImages(&kitty_handles, &vx, &tty);
+ kitty_handles.deinit();
+ }
+ var paste_buf: std.Io.Writer.Allocating = .init(gpa);
+ defer paste_buf.deinit();
+ var loop: Loop = .init(io, &tty, &vx);
+
+ // How a connected client leaves `localSession`, and the whole of the
+ // handover: it is set only once `detached_client.attempt` has come back
+ // GREETED, so every way of failing to attach leaves the local session
+ // running with this still null. By the time `localSession` returns non-null
+ // its scope has ended, which means every pane shell, watch, worker and
+ // mount of the local session is already away — the teardown is a scope
+ // exit rather than a second copy of the same defers.
+ var attached: ?detached_client.Client = null;
+ // The loop is STARTED inside `localSession`, because the initial forkpty
+ // has to happen before any thread of ours exists, and stopped by whichever
+ // loop was the last to use it: `localSession` itself when it is exiting for
+ // good, and this defer when it handed the terminal on. Registered before
+ // the call so LIFO puts the drain after `loop.stop()` — vaxis's reader must
+ // be joined before the queue is emptied, or a late post lands in a queue
+ // nobody drains again and its bytes leak.
+ defer if (attached != null) {
+ loop.stop();
+ drainAttachedQueue(&loop, gpa);
+ };
+ try localSession(init, opts, &tty, &vx, &loop, &kitty_handles, &paste_buf, &attached);
+ if (attached) |*client| {
+ // `detach` and not `deinit`: seven bytes that turn "the peer vanished"
+ // into "the peer left" in the session's log.
+ defer client.detach();
+ var a: Attach = .{
+ .gpa = gpa,
+ .client = client,
+ .loop = &loop,
+ .vx = &vx,
+ .tty = &tty,
+ // The `Shell`'s own paste buffer. Nothing is in flight in it: a
+ // bracketed burst cannot span the switch, because the builtin that
+ // caused the switch was a keystroke, and every `paste_start` clears
+ // it before it fills.
+ .paste_buf = &paste_buf,
+ // `caps_pending` deliberately keeps its default rather than
+ // inheriting the local session's: `enableDetectedFeatures` is
+ // idempotent mode-setting, and the `queueRefresh` it pairs with is
+ // wanted anyway on a screen that just changed which core draws it.
+ // `session` keeps its empty default for a reason worth stating: the
+ // name the `Attach` word carried lived in the core's own
+ // `attach_buf`, and that core is deinited by the time this runs. A
+ // later `Detach` therefore says "that session" rather than naming
+ // it, which is also all a bare `Attach` ever said.
+ };
+ attach_end = attachLoop(&a);
+ }
+}
+
+/// The session that lives in THIS process: the core, its pane shells, its
+/// watches, its acme filesystem, its workers and the loop that pumps them. A
+/// function of its own rather than the tail of `run` because that makes its
+/// teardown a SCOPE EXIT instead of a second copy of the same nine defers —
+/// and the `Attach` builtin needs exactly that teardown, in exactly that LIFO
+/// order, before an attached loop may draw on the same terminal. The hand-copy
+/// it replaces had 29 lines identical to these defers and stated its ordering
+/// contract in prose, so nothing but a reader could enforce it.
+///
+/// The terminal itself is NOT here: `tty`, `vx`, the alt screen, the `Loop`,
+/// the paste buffer and the kitty placements outlive this scope because the
+/// attached loop keeps drawing on them.
+fn localSession(
+ init: std.process.Init,
+ opts: pardes.Options,
+ tty: *vaxis.Tty,
+ vx: *vaxis.Vaxis,
+ loop: *Loop,
+ kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image),
+ paste_buf: *std.Io.Writer.Allocating,
+ attached: *?detached_client.Client,
+) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+
+ const allocs = pardes.allocators.init(gpa);
+ defer pardes.allocators.deinit();
+ var options = opts;
+ options.image_allocator = allocs.image;
+ options.pdf_allocator = allocs.pdf;
+ options.tree_sitter_allocator = allocs.tree_sitter;
+ // the 16 MiB static buffer behind every per-frame Surface
+ options.frame_allocator = allocs.frame;
+
pardes.image.start(io, allocs.image);
if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
pardes.syntax.start(allocs.tree_sitter);
@@ -557,17 +662,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
- var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa);
- defer {
- var iterator = kitty_handles.valueIterator();
- while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
- kitty_handles.deinit();
- }
var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
- var paste_buf: std.Io.Writer.Allocating = .init(gpa);
- defer paste_buf.deinit();
- var loop: Loop = .init(io, &tty, &vx);
// `--fs`: mount before the initial spawns, because those shells are the
// ones that need PARDES_FS in their environment, and before the first
@@ -583,8 +679,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
// like nested.zig's socket directory: another session may be living in it,
// and rmdir of a shared directory is not ours to attempt.
var fs = fs_service.start(gpa, core);
- // Covers the error paths only: the ordinary exit unmounts at the END OF
- // THE LOOP instead, see there.
+ // Covers the error paths and the handover; the ordinary exit unmounts at
+ // the END OF THE LOOP instead, see there.
defer if (fs) |f| f.deinit();
var sh: Shell = .{
@@ -593,12 +689,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
.lsp_gpa = allocs.lsp,
.core = core,
.prompt_rcs = &prompt_rcs,
- .loop = &loop,
- .vx = &vx,
- .tty = &tty,
- .kitty = &kitty_handles,
+ .loop = loop,
+ .vx = vx,
+ .tty = tty,
+ .kitty = kitty_handles,
.frame = &frame_arena,
- .paste_buf = &paste_buf,
+ .paste_buf = paste_buf,
// One inotify instance for every watched pane, opened here — before
// any thread exists — so the pre-loop effect drain below can already
// mark the file a positional path argument opened. -1 off linux:
@@ -614,6 +710,22 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
for (&sh.ptys) |*slot| if (slot.*) |*pt| {
pt.reader.cancel(io) catch {};
_ = libc.close(pt.file.handle);
+ // THE ONE DELTA BETWEEN THE TWO WAYS OUT OF THIS SCOPE, and the
+ // reason it is a condition rather than a comment: an exit leaves
+ // the closed master's SIGHUP to kill the shell and the kernel to
+ // collect it, which server.zig's `harvest` calls "the one
+ // bookkeeping cost a long-lived process pays that a frontend,
+ // which exits, never did". A handover does not exit — this process
+ // goes on drawing somebody else's session for hours — so a skipped
+ // `waitpid` is a zombie per pane held for all of it. SIGKILL and
+ // not the hangup alone because the wait has to be BOUNDED: the
+ // master is gone, so there is nothing left for the shell to print
+ // and no graceful exit left to give it, and SIGHUP is a signal it
+ // may decline while SIGKILL is not.
+ if (attached.* != null) {
+ _ = libc.kill(pt.pid, posix.SIG.KILL);
+ _ = libc.waitpid(pt.pid, null, 0);
+ }
slot.* = null;
};
// join the query worker BEFORE the drain below, or its late post
@@ -663,19 +775,22 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
while (core.nextEffect()) |effect| core.perform(effect);
try loop.start();
- defer loop.stop();
+ // ...and stopped here only when this scope is the last user of the
+ // terminal. A handover leaves vaxis's reader running for the attached loop,
+ // which is drawing on the same tty a moment later; `run` stops it then.
+ defer if (attached.* == null) loop.stop();
// resize watcher: plain detached thread (not io.concurrent — teardown
// joins those, and sigwait never returns); dies with the process
- (try std.Thread.spawn(.{}, winchWatch, .{ &loop, &vx, &tty })).detach();
+ (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach();
// ...and the nested-instance listener, detached for the same reason: a
// blocking accept(2) never returns either, so an io.concurrent task would
// hang the teardown that joins it.
- if (sock_fd >= 0) (try std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, &loop })).detach();
+ if (sock_fd >= 0) (try std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, loop })).detach();
// ...and the /dev/fuse poller, which is the same kind of thread again: it
// waits for POLLIN and posts, never touching the core or the descriptor's
// data. Joined by `Fs.deinit` rather than detached, because unlike accept4
// it CAN be woken — fuse.zig gives it a control pipe for exactly that.
- fs_service.wake(fs, &loop, wakeFs);
+ fs_service.wake(fs, loop, wakeFs);
// Capability handshake — SEND the probes, do not wait on them. This was
// queryTerminal(2ms), which blocks on a futex until DA1 comes back. The
// number has to beat one terminal round trip: a local terminal answers in
@@ -720,18 +835,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
// now threads are fine: start a reader task per pty
sh.threads_ok = true;
for (&sh.ptys, 0..) |*slot, id| if (slot.*) |*pt| {
- pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.gens[id], &loop });
+ pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.gens[id], loop });
};
// ...and the one file watcher. Started here rather than lazily on the
// first watched pane because the fd already exists and an unwatched
// inotify instance just parks in read(2) — one thread for the process,
// however many panes come and go.
- if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, &loop }) catch null;
+ if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null;
// The core owns the loop ORDER (see Pardes.pump); the outer `while` stays
// here rather than being `core.run` for one reason: Restore swaps the
// whole core, and a core cannot replace itself from inside its own frame.
- while (!core.quit) {
+ frames: while (!core.quit) {
try core.pump(host);
// Restore builtin: swap in a core rebuilt from the dump; the live
// shells die with their masters (readers canceled, gens bumped so
@@ -761,9 +876,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
.{ .text = 0 },
);
_ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false);
- var image_iterator = kitty_handles.valueIterator();
- while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
- kitty_handles.clearRetainingCapacity();
+ clearNativeImages(kitty_handles, vx, tty);
nc.native_images = vx.caps.kitty_graphics;
core.deinit();
core = nc;
@@ -776,6 +889,35 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix);
}
}
+ // `Attach [name]`: hand this terminal to a detached session and stop
+ // being a session at all. CONNECTING IS NOT BEING ATTACHED, which is
+ // why this asks `detached_client.attempt` for a GREETED client and not
+ // for a socket: `Client.open` writes a hello and returns, and every way
+ // a session says no — `refuse .version` for a session built from other
+ // bytes, `.full`, `.quitting`, or a plain `quit` from one that ended in
+ // the same round — arrives after a successful `connect(2)`. A swap that
+ // trusted the connect would already have SIGKILLed every pane shell,
+ // unmounted the filesystem and freed every undo history by the time it
+ // decoded the refusal.
+ //
+ // So `attached` is set only with the welcome in hand, and until it is,
+ // NOTHING here has been touched: a failed `Attach` costs one message
+ // row and leaves every pane, every shell and every undo history where
+ // it was. The teardown that follows is this function's own defers,
+ // reached by leaving its scope.
+ if (core.takeAttach()) |req| {
+ var attempt = detached_client.attempt(gpa, req.name, core.screen_w, core.screen_h);
+ switch (attempt) {
+ .greeted => |client| {
+ attached.* = client;
+ break :frames;
+ },
+ else => {
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(req.pane, attemptEnd(&attempt, req.name).row(&mbuf));
+ },
+ }
+ }
}
// THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below.
// `loop.stop()` joins a reader parked in `read(2)` on the tty, so it does
@@ -783,7 +925,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
// exit must not spend that wait holding a mount nobody is serving. A
// client blocked on `<id>/event` when the last pane is deleted through
// `ctl` then gets ENOTCONN at once instead of hanging until somebody
- // touches the keyboard.
+ // touches the keyboard. A handover skips this and lets the deferred
+ // unmount do it, because it does not stop the loop and so never waits.
if (fs) |f| {
f.deinit();
fs = null;
@@ -791,6 +934,35 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
}
}
+/// Free every kitty placement this session put on the terminal and forget them.
+/// THREE callers and one reason: the pixels live in the TERMINAL, not in the
+/// core, so a core that is replaced (`Restore`), handed away (`Attach`) or
+/// simply gone (the exit) leaves placements the next frames know nothing about
+/// and would paint text around. The exit's own caller follows it with `deinit`;
+/// the two mid-session ones keep the map's capacity for the frames after.
+fn clearNativeImages(
+ kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image),
+ vx: *vaxis.Vaxis,
+ tty: *vaxis.Tty,
+) void {
+ var iterator = kitty_handles.valueIterator();
+ while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
+ kitty_handles.clearRetainingCapacity();
+}
+
+/// Empty the event queue an attached loop leaves behind, AFTER `loop.stop()`
+/// has joined vaxis's reader. Two of its events own gpa bytes — a decoded paste
+/// (a bracketed burst, or an OSC 52 reply to the session's `read_clipboard`)
+/// and a nested-instance command line — and the thread that posts the second
+/// cannot be joined at all, so the drain is not optional on either path out.
+fn drainAttachedQueue(loop: *Loop, gpa: std.mem.Allocator) void {
+ while (loop.tryEvent() catch null) |ev| switch (ev) {
+ .paste => |b| gpa.free(@constCast(b)),
+ .command => |line| gpa.free(line),
+ else => {},
+ };
+}
+
/// Everything the terminal shell owns and the core cannot: the ptys, the
/// inotify table, the worker futures, and the one thread allowed to touch
/// `tty.writer()`. This struct IS the `Host.ctx`.
@@ -1194,7 +1366,7 @@ const Shell = struct {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
+ const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
// report the pane's starting directory back to the core (tags). The
// slot needs no occupancy reset: nothing is remembered, and the next
@@ -1210,7 +1382,7 @@ const Shell = struct {
fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
const s = of(ctx);
- if (s.ptys[pane]) |pt| writeFd(pt.file.handle, bytes);
+ if (s.ptys[pane]) |pt| host_io.writeFd(pt.file.handle, bytes);
}
fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
@@ -1236,7 +1408,7 @@ const Shell = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
- if (!writeFileBytes(path, bytes)) return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
// our own write is about to come back as a watch event: restamp from
// the bytes we just put there so it reads as "no change". Only when
// this IS the pane's watched file — a `Save <elsewhere>` must not
@@ -1258,7 +1430,7 @@ const Shell = struct {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
const path = pardes.dump.outPath(&pbuf) orelse return;
- if (!writeFileBytes(path, bytes)) return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
s.core.setLastDump(path);
}
@@ -1298,31 +1470,27 @@ const Shell = struct {
}
// ---- the desktop --------------------------------------------------------
+ //
+ // The three effects a detached session still puts on the wire
+ // (`wire.ServerTag` 0x10..), because each of them needs the display a
+ // human is actually looking at rather than the machine the core runs on.
+ // These are the `Host.ctx` shims and nothing else: the terminal work is
+ // `copyToClipboard`/`requestClipboard` below this struct, so an attached
+ // frontend serving `set_clipboard`/`read_clipboard` writes the same escape
+ // sequences from the same lines.
- /// mirror the core's yank register out via OSC 52
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
const s = of(ctx);
- if (text.len == 0) return;
- s.vx.copyToSystemClipboard(s.tty.writer(), text, s.gpa) catch {};
+ copyToClipboard(s.vx, s.tty, s.gpa, text);
}
- /// ...and the other direction, OSC 52 read. The answer arrives on vaxis's
- /// reader thread as an ordinary `.paste` event and reaches the core
- /// through the same path an outer bracketed paste does — this request is
- /// the only wiring it needs. "The answer arrives" is the optimistic
- /// reading: a clipboard READ is an exfiltration primitive and terminals
- /// treat it as one (ghostty prompts by default, xterm ships it off, a
- /// multiplexer or ssh link may eat it), and a refusal looks exactly like
- /// silence. So the core's pending request is dropped by the next keystroke
- /// rather than pasting minutes late, and `SPC p` in a locked-down terminal
- /// honestly does nothing.
fn readClipboard(ctx: ?*anyopaque) void {
const s = of(ctx);
- s.vx.requestSystemClipboard(s.tty.writer()) catch {};
+ requestClipboard(s.vx, s.tty);
}
fn openLink(_: ?*anyopaque, url: []const u8) void {
- look.openLink(url); // desktop browser
+ look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly
}
// ---- work that must leave the loop --------------------------------------
@@ -1395,6 +1563,31 @@ const Shell = struct {
}
};
+/// Mirror a yank register out via OSC 52. Free functions over the terminal
+/// they write to rather than `Shell` methods, because the clipboard is the one
+/// piece of host work that survived the move into the daemon and BOTH loops in
+/// this file perform it: `Shell` for its own core's `Effect.set_clipboard`, and
+/// `Attach` for a detached session's `wire.ServerMsg.set_clipboard`. One
+/// escape sequence written in two places is one that drifts.
+fn copyToClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty, gpa: std.mem.Allocator, text: []const u8) void {
+ if (text.len == 0) return;
+ vx.copyToSystemClipboard(tty.writer(), text, gpa) catch {};
+}
+
+/// ...and the other direction, OSC 52 read. The answer arrives on vaxis's
+/// reader thread as an ordinary `.paste` event and reaches whoever asked —
+/// the local core through `Shell`, the session through `ClientTag.event` —
+/// by the same path an outer bracketed paste takes; this request is the only
+/// wiring it needs. "The answer arrives" is the optimistic reading: a
+/// clipboard READ is an exfiltration primitive and terminals treat it as one
+/// (ghostty prompts by default, xterm ships it off, a multiplexer or ssh link
+/// may eat it), and a refusal looks exactly like silence. So the core's
+/// pending request is dropped by the next keystroke rather than pasting
+/// minutes late, and `SPC p` in a locked-down terminal honestly does nothing.
+fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
+ vx.requestSystemClipboard(tty.writer()) catch {};
+}
+
/// Answer a language query off the event loop and post the rows back. This is
/// the whole async execution model: the same shape as readPty — do the slow
/// thing on a worker, hand the result to the loop as an event, let the core
@@ -1503,42 +1696,6 @@ fn wakeFs(ctx: ?*anyopaque) void {
_ = loop.tryPostEvent(.fs_ready) catch {};
}
-/// `core` is null in an `--attach` frontend, which forks the pane shells for a
-/// core that is in another process entirely. The two things it is used for are
-/// both messages BACK to that core — which pane serial the child's environment
-/// should name, and which binary was actually executed — and neither has a
-/// place on the detached wire (see wire.zig): a detached session's `--fs`
-/// stays in the session, and `acknowledgeShell` has no `ClientTag`. So both
-/// are skipped rather than faked, and the pane tag in a detached session
-/// simply does not name its shell.
-fn forkShell(core: ?*pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) struct { file: std.Io.File, pid: posix.pid_t } {
- var master: c_int = undefined;
- // resolved BEFORE the fork, into this frame, which the child inherits:
- // nothing between fork and exec may allocate, and a PATH search would
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
- // ...and so is the pane's own address on the control filesystem, for a
- // second reason on top of that one: acme puts `winid` in the child, which
- // is safe there only because rfork(RFENVG) has just given it a private
- // environment group. See fs_service.exportPaneEnv.
- fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0);
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- // the blocked-SIGWINCH mask survives fork AND exec — unblock it or
- // bash/vim in the pane would never see resizes (sigprocmask is
- // async-signal-safe)
- var set = posix.sigemptyset();
- posix.sigaddset(&set, posix.SIG.WINCH);
- posix.sigprocmask(posix.SIG.UNBLOCK, &set, null);
- if (cwd) |c| _ = chdir(c);
- _ = execv(spawn.path, &spawn.argv);
- _exit(127);
- }
- if (pid > 0) if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
- return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
-}
-
fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: u32, loop: *Loop) anyerror!void {
var read_buf: [0x10000]u8 = undefined;
var reader = pty.readerStreaming(io, &read_buf);
@@ -1688,25 +1845,6 @@ fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void {
win.setCursorShape(if (bar) .beam else .default);
}
-/// Create-or-truncate `path` and put `bytes` there. The half of `write_file`
-/// that is nothing but the filesystem, so that the frontend which has a disk
-/// and no core and the host which has both write a file the same way.
-/// Everything else in `Shell.writeFile` — the watch restamp, the "saved"
-/// message — is the CORE's memory of the write and stays with whoever owns
-/// one. False is a save that did not happen, which no caller may report as
-/// one.
-fn writeFileBytes(path: []const u8, bytes: []const u8) bool {
- var pathbuf: [4096:0]u8 = undefined;
- if (path.len >= pathbuf.len) return false;
- @memcpy(pathbuf[0..path.len], path);
- pathbuf[path.len] = 0;
- const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return false;
- writeFd(fd, bytes);
- _ = libc.close(fd);
- return true;
-}
-
fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
return .{
.fg = vaxisColor(s.fg),
@@ -1737,20 +1875,16 @@ fn vaxisColor(c: pardes.Color) vaxis.Color {
};
}
-fn writeFd(fd: c_int, data: []const u8) void {
- var off: usize = 0;
- while (off < data.len) {
- const n = libc.write(fd, data[off..].ptr, data.len - off);
- if (n < 0) {
- if (libc.errno(n) == .INTR) continue;
- return;
- }
- off += @intCast(n);
- }
-}
-
// ---------------------------------------------------------------------------
-// --attach: a terminal, a socket, and no core
+// Attached: a terminal, a socket, and no core
+//
+// Reached two ways — `--attach[=<name>]` on the command line, and the `Attach`
+// builtin handing a running local session's terminal to a detached one — and
+// identical past the connect. NOTHING below this line forks a shell, writes a
+// file or watches a path: the daemon owns every machine-local effect now
+// (src/detached/server.zig), and the three that are still on the wire
+// (`wire.ServerTag` 0x10..) are there because the clipboard and the browser
+// are the human's, not the machine's.
// ---------------------------------------------------------------------------
/// Why an `--attach` frontend stopped, and where its exit status comes from.
@@ -1772,17 +1906,40 @@ const AttachEnd = union(enum) {
refused: wire.Refusal,
/// the link died, or the stream stopped making sense
lost: anyerror,
- /// the connect landed and the session hung up before the hello was
- /// answered: a refusal whose reason raced the close (see `attachSession`)
+ /// the connect landed and the session hung up before its reason arrived: a
+ /// refusal whose six bytes raced the close (server.zig `refuseFd`)
rejected,
+ /// ...and the other silence: it accepted, kept the slot, and never greeted
+ /// us at all inside client.zig's `attempt` deadline
+ silent,
+ /// `Detach` in an attached frontend: THIS frontend leaves and the session
+ /// does not, which is the whole difference between it and `.none`. The name
+ /// is what to come back to, and empty when this frontend never knew it (an
+ /// `Attach` builtin's bare form: the core that held the word is gone by the
+ /// time the attached loop runs).
+ detached: []const u8,
/// The nonzero exit lives HERE for the same reason the message does: every
/// teardown this process owes is a defer registered after this one, so by
/// the time this runs they have all run and there is nothing left to skip.
fn report(e: AttachEnd, io: std.Io) void {
var buf: [512]u8 = undefined;
+ // Set by the one ending that is not a failure. `Detach` is a word the
+ // user typed, so leaving is success: the line goes to STDOUT and the
+ // exit status is untouched, because there is still a session there to
+ // come back to. tmux's `[detached]` line is the same sentence for the
+ // same reason.
+ var ok = false;
const text: []const u8 = switch (e) {
.none => return,
+ .detached => |name| blk: {
+ ok = true;
+ break :blk if (name.len != 0) std.fmt.bufPrint(
+ &buf,
+ "pardes: detached from '{s}', which is still running — come back with `pardes --attach={s}`\n",
+ .{ name, name },
+ ) catch "pardes: detached; that session is still running\n" else "pardes: detached; that session is still running — come back with `pardes --attach`\n";
+ },
.no_session => |name| std.fmt.bufPrint(
&buf,
"pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n",
@@ -1802,130 +1959,83 @@ const AttachEnd = union(enum) {
.lost => |err| std.fmt.bufPrint(&buf, "pardes: detached session lost: {t}\n", .{err}) catch
"pardes: detached session lost\n",
.rejected => "pardes: that session hung up on the connect — every frontend slot is taken (32), or it is shutting down. `PARDES_LOG=1` on the session names which\n",
+ .silent => "pardes: that session accepted the connection and then never greeted it — it is wedged inside its own loop, or something else is listening at that path. `PARDES_LOG=1` on the session says which\n",
};
- std.Io.File.stderr().writeStreamingAll(io, text) catch {};
- std.process.exit(1);
+ const out = if (ok) std.Io.File.stdout() else std.Io.File.stderr();
+ out.writeStreamingAll(io, text) catch {};
+ if (!ok) std.process.exit(1);
}
-};
-/// The session `--attach` meant. `--attach=<name>` is the name it says; bare
-/// `--attach` is THE session, because 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 session listening that is the one meant; with none or
-/// several this says which case it is instead of picking one. Null means "do
-/// not open a terminal", with `end` already saying why.
-///
-/// 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 not
-/// be able to disagree about where a session lives.
-fn sessionName(buf: *[detached_server.path_max]u8, requested: []const u8, end: *AttachEnd) ?[]const u8 {
- if (requested.len != 0) {
- // A name is taken at its word — the connect in `attachSession` is the
- // authority on whether anything is listening — except for the one case
- // that is worth catching before a terminal is opened at all: a typo,
- // where there is no socket file of that name whatsoever. Getting that
- // wrong is the common failure, and the alternative is a full-screen
- // alt-screen flash on the way to a one-line message.
- var one_buf: [detached_server.path_max]u8 = undefined;
- const one = detached_server.sessionPath(&one_buf, requested) orelse {
- end.* = .{ .no_session = requested };
- return null;
+ /// The same reason on ONE pane message row, for the `Attach` builtin. It
+ /// exists because that path does not exit: `report` writes to a cooked main
+ /// screen on the way out of the process and can spend a clause on advice,
+ /// while this shares a row with a filename in a session that goes on
+ /// running. Same vocabulary, no `pardes:` prefix and no newline.
+ fn row(e: AttachEnd, buf: []u8) []const u8 {
+ return switch (e) {
+ // `report` reads `.none` as "exit 0, say nothing", and a message
+ // row only ever shows a failure — but a session that says `quit`
+ // before it greets is the one way this arm could be reached, and
+ // that is what it says.
+ .none => "attach: that session ended",
+ .no_session => |name| std.fmt.bufPrint(buf, "attach: no session '{s}'", .{name}) catch
+ "attach: no session under that name",
+ .nothing_detached => "attach: no detached session is running",
+ .ambiguous => |n| std.fmt.bufPrint(buf, "attach: {d} sessions running, name one", .{n}) catch
+ "attach: several sessions running, name one",
+ .refused => |why| switch (why) {
+ .version => "attach: that session is another build of pardes",
+ .full => "attach: that session has every frontend slot taken",
+ .quitting => "attach: that session is ending",
+ },
+ .lost => |err| std.fmt.bufPrint(buf, "attach: {t}", .{err}) catch "attach: link lost",
+ .rejected => "attach: that session hung up on the connect",
+ .silent => "attach: that session accepted and never greeted",
+ // Never asked for: `attemptEnd` is the only caller and a connect
+ // that has not happened yet cannot have been detached from. Worded
+ // rather than left to an `else`, so the arm somebody adds next
+ // still has to be thought about.
+ .detached => "attach: detached from that session",
};
- const F_OK: c_int = 0;
- if (libc.access(one, F_OK) != 0) {
- end.* = .{ .no_session = requested };
- return null;
- }
- return requested;
- }
- const probe_name = "0";
- var probe_buf: [detached_server.path_max]u8 = undefined;
- const probe = detached_server.sessionPath(&probe_buf, probe_name) orelse {
- end.* = .nothing_detached;
- return null;
- };
- 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: [detached_server.path_max:0]u8 = undefined;
- const dir = std.fs.path.dirname(probe) orelse "";
- if (dir.len == 0 or dir.len >= dir_buf.len) {
- end.* = .nothing_detached;
- return null;
- }
- @memcpy(dir_buf[0..dir.len], dir);
- dir_buf[dir.len] = 0;
- const d = libc.opendir(dir_buf[0..dir.len :0]) orelse {
- end.* = .nothing_detached;
- return null;
- };
- 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) {
- end.* = if (found == 0) .nothing_detached else .{ .ambiguous = found };
- return null;
- }
- return buf[0..len];
-}
+};
-/// `--attach[=<name>]`: the frontend half of a detached session. This process
-/// owns a terminal and a socket, and the `Pardes` is in the session process
-/// (src/detached/). The whole job is client.zig's two sentences — send the
-/// input it collects, draw the frames it is sent — plus the real host work a
-/// daemon has no way to do and asks a frontend for: fork a shell on a real tty,
-/// put bytes on a real disk, reach a real clipboard.
+/// `--attach[=<name>]` and the `Attach` builtin: the frontend half of a
+/// detached session. This process owns a terminal and a socket; the `Pardes`
+/// is in the session process (src/detached/). The whole job is client.zig's
+/// two sentences — send the input it collects, draw the frames it is sent —
+/// and since the daemon took its own IO back there is nothing else in it.
+///
+/// THAT DELETION IS THE POINT. A frontend used to serve `spawn`, `pty_write`,
+/// `pty_resize`, `write_file`, `write_dump`, `watch_file` and `dump_themes`
+/// off the wire, which put every pane's shell in whichever frontend happened
+/// to fork it and stopped that pane's output the moment that frontend left —
+/// a daemon whose whole promise is outliving frontends killed your shells. A
+/// unix socket means the two ends share a machine, so the daemon forks and
+/// writes and watches for itself (src/host_io.zig, src/file_watch.zig) and
+/// `wire.ServerTag` keeps exactly three effects, 0x10..: the clipboard both
+/// ways and the browser, because each of those needs the display a human is
+/// actually looking at.
///
/// It is NOT a `Shell`, and the difference is not size. `Shell` IS the
/// `Host.ctx` of a core in THIS process, and its methods reach into that core
-/// on nearly every line — a pane's message row, a watch generation taken off
-/// the pane's live text, `acknowledgeShell`, `setCwd`. With no core those are
-/// not cheaper versions of the same work, they are absent, and each one is
-/// named below where it goes missing. What the two do genuinely share is
-/// shared: the cell walk, the key and mouse vocabularies, the paste ceiling,
-/// `forkShell`, `readPty`, `writeFileBytes`.
+/// on nearly every line — a pane's message row, `acknowledgeShell`, `setCwd`,
+/// a watch generation taken off the pane's live text. With no core those are
+/// not cheaper versions of the same work, they are absent. What the two
+/// genuinely share is shared: the cell walk, the key and mouse vocabularies,
+/// the paste ceiling, `copyToClipboard`, `requestClipboard`.
const Attach = struct {
- io: std.Io,
gpa: std.mem.Allocator,
client: *detached_client.Client,
loop: *Loop,
vx: *vaxis.Vaxis,
tty: *vaxis.Tty,
- prompt_rcs: *const shell_bin.PromptRcs,
paste_buf: *std.Io.Writer.Allocating,
- /// Where the config directory came from is main.zig's answer, carried in
- /// `Options` — the only field of it this frontend reads, since every other
- /// one describes a core that is elsewhere.
- config_dir: ?[]const u8,
- ptys: [pardes.MAX_PANES]?Pty = @splat(null),
- gens: [pardes.MAX_PANES]u32 = @splat(0),
- inotify_fd: c_int,
- watches: file_watch.Table = @splat(null),
- /// What each watched slot is watching. `file_watch.Table` remembers the
- /// directory mark and the accepted generation but not the pathname — the
- /// in-process host reads that back off the pane, and this one has no panes,
- /// so the path the `watch_file` message carried is kept here.
- watch_paths: [pardes.MAX_PANES]?[]u8 = @splat(null),
- watch_task: ?std.Io.Future(anyerror!void) = null,
+ /// The session this frontend asked for, borrowed for the loop's lifetime
+ /// and only so `Detach` can name what to come back to. Empty when it is not
+ /// knowable here — see `AttachEnd.detached`.
+ session: []const u8 = &.{},
caps_pending: bool = true,
- check_files: bool = false,
in_paste: bool = false,
/// A frame landed. Painted once at the end of the round rather than where
/// it arrives: several can be decoded out of one poll and only the last of
@@ -1936,21 +2046,22 @@ const Attach = struct {
fn send(a: *Attach, ev: pardes.Event) ?AttachEnd {
a.client.send(.{ .event = ev }) catch |err| switch (err) {
// A message this protocol cannot carry, which is not a link that
- // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB),
- // and the one event that can reach it is a `file_changed` for a
- // watched file between that and `look.readFile`'s own 256 MiB cap.
- // Dropping it costs one reload; treating it as a hangup would cost
- // the session.
+ // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB).
+ // One event still reaches it now that the watched files are the
+ // daemon's — an OSC 52 clipboard reply, whose size is whatever the
+ // terminal handed vaxis and which nothing in this file bounds
+ // (`max_paste_bytes` bounds the bracketed-paste assembly, not a
+ // decoded reply). Dropping it costs one paste; treating it as a
+ // hangup would cost the session.
error.Overlong, error.NoSpace => return null,
else => return .{ .lost = err },
};
return null;
}
- /// One decoded message from the session. Every arm of `wire.ServerMsg` is
- /// named and none of them is a catch-all: a session goes on asking for what
- /// it is not given, and the two this frontend genuinely cannot do say so
- /// where they are handled rather than vanishing into an `else`.
+ /// One decoded message from the session. `wire.ServerMsg` has eight arms
+ /// and so has this switch — no catch-all, so a protocol that grows a ninth
+ /// stops compiling here rather than quietly ignoring it.
fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd {
switch (msg) {
// Already applied to the client's slot and geometry; the full frame
@@ -1961,41 +2072,22 @@ const Attach = struct {
// returns, so all that is left is to say the screen moved.
.frame => a.dirty = true,
.quit => return .none,
-
- .spawn => |v| a.spawn(v.pane, v.cwd),
- .pty_write => |v| if (a.ptys[v.pane]) |pt| writeFd(pt.file.handle, v.bytes),
- .pty_resize => |v| if (a.ptys[v.pane]) |pt| {
- const ws: posix.winsize = .{ .row = v.rows, .col = v.cols, .xpixel = 0, .ypixel = 0 };
- _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws));
- },
- .write_file => |v| a.writeFile(v.pane, v.path, v.bytes),
- // The dump lands on this frontend's disk. WHERE it landed is the
- // core's own memory of it (`setLastDump`), and there is no
- // `ClientTag` to carry a path back, so a detached `Dump` writes the
- // file and the session cannot then name it.
- .write_dump => |bytes| {
- var pbuf: [1024:0]u8 = undefined;
- const path = pardes.dump.outPath(&pbuf) orelse return null;
- _ = writeFileBytes(path, bytes);
- },
- .watch_file => |v| a.watchFile(v.pane, v.path, v.on),
- // NOT DOABLE FROM HERE, and it is the wire's shape rather than an
- // omission: this message carries `{generation, on}`, the theme
- // file's PATH lives in the core (`themeFileRequest`), and there is
- // no message that would carry the reloaded bytes back. So a
- // detached session does not live-reload a theme file. Named
- // anyway, because the alternative is an `else` that would also
- // swallow the next arm somebody adds to the protocol.
- .watch_theme => {},
- .dump_themes => a.dumpThemes(),
- .set_clipboard => |text| if (text.len != 0) {
- a.vx.copyToSystemClipboard(a.tty.writer(), text, a.gpa) catch {};
- },
+ // ...and its sibling, which is the same exit for the opposite
+ // reason: `quit` is the session ending under every frontend, and
+ // `Detach` is THIS frontend leaving one that carries on. The
+ // session keeps its panes, its shells and its other frontends, so
+ // there is nothing to report as a failure and something to come
+ // back to — see `AttachEnd.detached`.
+ .detach => return .{ .detached = a.session },
+ // The three that are left, served by the same lines the local
+ // `Shell` runs for its own core's effects: this terminal's OSC 52
+ // pair and this desktop's browser.
+ .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text),
// The answer is not a reply message: it comes back as an ordinary
- // `Event.paste`, which is the same asynchronous shape the
- // in-process host has (see `Shell.readClipboard`), and it arrives
- // through the `.paste` arm of `apply` like any other.
- .read_clipboard => a.vx.requestSystemClipboard(a.tty.writer()) catch {},
+ // `Event.paste` through the `.paste` arm of `apply`, like any other
+ // input, which is the same asynchronous shape `pull_read_clipboard`
+ // has in-process.
+ .read_clipboard => requestClipboard(a.vx, a.tty),
.open_link => |url| look.openLink(url),
}
return null;
@@ -2008,10 +2100,16 @@ const Attach = struct {
// is `Shell.waitInput`'s animation clock, and an animation runs
// where the core is — the session sleeps on its own frame interval
// (server.zig `nap`) and the frames simply arrive. `fs_ready`
- // belongs to `--fs`, which wire.zig keeps in the detached process.
- // `lsp_done`/`pipe_done` answer work the core dispatches, and it
- // dispatches it there.
- .nop, .tick, .fs_ready, .lsp_done, .pipe_done => {},
+ // belongs to `--fs`, which lives with the core. `lsp_done` and
+ // `pipe_done` answer work the core dispatches, and it dispatches it
+ // there. `pty_read`, `pty_eof` and `files_changed` are the ones
+ // that MOVED: the daemon forks the pane shells and holds the
+ // inotify instance now, so the only descriptors this process reads
+ // are its terminal and one socket. An in-place switch (`Attach` in
+ // a local session) cancels its readers and its watcher and drains
+ // this queue before the attached loop starts, so not even a late
+ // post from the session it just left arrives here.
+ .nop, .tick, .fs_ready, .lsp_done, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
.focus_in => {},
.focus_out => return a.send(.pointer_leave),
@@ -2024,18 +2122,6 @@ const Attach = struct {
a.dirty = true;
a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err };
},
- .pty_read => |pr| {
- defer a.gpa.free(pr.bytes);
- return a.send(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
- },
- .pty_eof => |e| if (a.gens[e.id] == e.gen) {
- if (a.ptys[e.id]) |*pt| {
- pt.reader.await(a.io) catch {}; // reader just finished; join it or its future leaks
- _ = libc.close(pt.file.handle);
- a.ptys[e.id] = null;
- }
- return a.send(.{ .eof = .{ .pane = @intCast(e.id) } });
- },
.key_press => |key| if (a.in_paste) {
const bytes = pasteBytes(key);
const room = max_paste_bytes -| a.paste_buf.written().len;
@@ -2058,126 +2144,23 @@ const Attach = struct {
const pasted = a.paste_buf.written();
if (pasted.len > 0) return a.send(.{ .paste = pasted });
},
+ // A pardes launched inside a pane shell hands its file to the
+ // nearest pardes ANCESTOR (nested.zig `outer`), and now that the
+ // daemon forks those shells that ancestor is the daemon — which is
+ // why `attachSession` binds no listener at all. The one line that
+ // still reaches this arm is a switch racing itself: a local session
+ // whose own child wrote to `localSession`'s listener in the moment
+ // before `Attach` gave the terminal away, on a thread that is
+ // detached and so cannot be joined ahead of the queue drain. It
+ // goes over the wire, which is what `ClientTag.command` is for.
.command => |line| {
defer a.gpa.free(line);
return a.send(.{ .command = line });
},
- .files_changed => a.check_files = true,
- }
- return null;
- }
-
- fn spawn(a: *Attach, pane: u8, cwd: []const u8) void {
- // The in-process host's reaping rule, and its reason: 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.
- if (a.ptys[pane]) |*old| {
- old.reader.cancel(a.io) catch {};
- _ = libc.close(old.file.handle);
- a.ptys[pane] = null;
- }
- a.gens[pane] +%= 1;
- 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);
- }
- // The SESSION's grid, which is what its panes are laid out against; the
- // pane's own size follows immediately as a `pty_resize`.
- //
- // `config.default_shell` and not the session's configured one: `Shell
- // <bin>` is a core setting, no message carries it, and inventing a
- // second place that decides which shell runs would be worse than one
- // that is occasionally the default. `shell_bin.resolve` falls back from
- // there exactly as it does for a whole session.
- const child = forkShell(null, pane, a.prompt_rcs, config.default_shell, cwd_z, a.client.rows, a.client.cols, null);
- a.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
- // Unconditional, unlike `Shell.spawn`'s `threads_ok`: every spawn here
- // arrives over a socket this loop is already running, so there is no
- // pre-loop drain to be in.
- if (a.ptys[pane]) |*pt| {
- pt.reader = a.io.concurrent(readPty, .{ a.io, a.gpa, pt.file, @as(usize, pane), a.gens[pane], a.loop }) catch pt.reader;
- }
- }
-
- fn writeFile(a: *Attach, pane: u8, path: []const u8, bytes: []const u8) void {
- if (!writeFileBytes(path, bytes)) return;
- // Our own write is about to come back as a watch event: restamp from
- // the bytes we just put there so it reads as "no change". Only when
- // this IS the path this slot is watching — a `Save <elsewhere>` must
- // not silence a real change to the file the pane has open. Same rule as
- // `Shell.writeFile`; the comparison is against the path the session
- // asked us to watch, because there is no pane here to ask.
- //
- // The "saved <path>" message that host also writes is a pane's message
- // row, which belongs to the core: a detached save is silent.
- const watched = a.watch_paths[pane] orelse return;
- if (!std.mem.eql(u8, watched, path)) return;
- if (a.watches[pane]) |*w| w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) };
- }
-
- fn watchFile(a: *Attach, pane: u8, path: []const u8, on: bool) void {
- if (a.watch_paths[pane]) |old| a.gpa.free(old);
- a.watch_paths[pane] = null;
- if (!on or path.len == 0) return file_watch.watchPane(a.inotify_fd, &a.watches, pane, null, 0, .{ .text = 0 });
- const owned = a.gpa.dupe(u8, path) catch return;
- // Seeded from what is on disk RIGHT NOW, so the first `file_changed`
- // this sends is the first edit that is not already in the core. The
- // in-process host takes the same hash off the pane's live text; that
- // text is a socket away, and the file it came from is not.
- var hash: u64 = 0;
- if (look.readFile(a.gpa, owned)) |bytes| {
- hash = std.hash.Wyhash.hash(0, bytes);
- a.gpa.free(bytes);
- } else |_| {}
- a.watch_paths[pane] = owned;
- file_watch.watchPane(a.inotify_fd, &a.watches, pane, owned, 0, .{ .text = hash });
- }
-
- /// A coalesced inotify wake: re-read every watched path and hand the
- /// session the ones that really changed. It sends BYTES rather than
- /// reloading anything, because the text belongs to the core — which is
- /// exactly why `ClientTag` has a `file_changed` at all.
- fn reloadWatched(a: *Attach) ?AttachEnd {
- if (!a.check_files) return null;
- a.check_files = false;
- for (a.watch_paths, 0..) |slot, pane| {
- const path = slot orelse continue;
- const w = if (a.watches[pane]) |*entry| entry else continue;
- const bytes = look.readFile(a.gpa, path) catch continue;
- defer a.gpa.free(bytes);
- const hash = std.hash.Wyhash.hash(0, bytes);
- switch (w.generation) {
- .text => |accepted| if (accepted == hash) continue,
- // Never stored by this frontend: it cannot tell a PDF pane from
- // a text one (the message carries a path and nothing else), so
- // every slot is hashed and the core decides what the bytes mean
- // — `applyWatchedFileChanged` reopens the path for a PDF pane
- // and ignores them.
- .pdf => {},
- }
- // Committed here rather than after an acknowledgement, because
- // there is none: `file_changed` is a one-way event like every other
- // input on this wire. A snapshot the core rejects is therefore not
- // retried until the file changes again — the same bound the
- // in-process host lives with whenever a reload fails.
- w.generation = .{ .text = hash };
- if (a.send(.{ .file_changed = .{ .pane = @intCast(pane), .bytes = bytes } })) |end| return end;
}
return null;
}
- /// The themes land on this frontend's disk. Where they went is reported on
- /// a pane's message row by the in-process host, and that row is the core's,
- /// so a detached dump is silent — the same shape as `write_dump` above.
- fn dumpThemes(a: *Attach) void {
- const dir = a.config_dir orelse return;
- const out = user_config.dumpThemes(a.io, a.gpa, dir, pardes.themes) catch return;
- a.gpa.free(out);
- }
-
/// The capability handshake, resolved on the loop exactly as
/// `Shell.pollFrame` resolves it and for its reason: the replies land on
/// vaxis's reader thread, and this is the only thread allowed to write to
@@ -2205,32 +2188,83 @@ const Attach = struct {
}
};
-/// How long the frontend may sleep on the socket before it looks at the
-/// terminal. This loop has TWO event sources and can block on only one of
-/// them: vaxis delivers the terminal's events on its reader thread into a
-/// mutex/condvar queue, which has no descriptor to hand `poll(2)` alongside
-/// the socket — client.zig's `wait` takes a timeout for exactly that reason
-/// ("the terminal it draws on is polled by whoever owns that"), and this is
-/// whoever.
-///
-/// 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 of
-/// the ceiling is 125 poll rounds a second on a frontend nobody is touching,
-/// and it is measurably below the noise of what an idle pardes already costs:
-/// on this machine (i7-11700, 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 ever stops being true.
-const attach_poll_ms = 8;
+/// One `detached_client.Attempt` that did NOT come back with a client, in this
+/// file's own vocabulary. `requested` is what the user actually typed, because
+/// the union has a single `no_session` where this file has two ends for it: a
+/// name that resolved to nothing is a typo to correct, and no name at all is a
+/// session to start.
+fn attemptEnd(a: *const detached_client.Attempt, requested: []const u8) AttachEnd {
+ return switch (a.*) {
+ // Both callers take the client out of the `.greeted` arm themselves, so
+ // this is only ever asked about a failure; `.none` is what "nothing to
+ // report" is spelled as everywhere else in this union.
+ .greeted => .none,
+ .no_session => if (requested.len != 0) .{ .no_session = requested } else .nothing_detached,
+ .ambiguous => |found| .{ .ambiguous = found },
+ .refused => |why| .{ .refused = why },
+ .silent => .silent,
+ // A hangup with no reason decoded is what `rejected` was written for:
+ // server.zig's `refuseFd` writes six bytes and closes in the same pass,
+ // so the close can beat the reason onto the socket. client.zig's `give`
+ // already prefers a refusal it did decode, so an `error.Closed` that
+ // reaches here is that race and nothing else.
+ .lost => |err| if (err == error.Closed) .rejected else .{ .lost = err },
+ };
+}
+
+/// The attached loop: two event sources, one screen, no core. A function of its
+/// own because there are two ways to become attached and only one loop —
+/// `--attach` on the command line (`attachSession`, which opens the terminal
+/// for it) and the `Attach` builtin (see `localSession`, whose terminal already
+/// had one) — and past the connect the two are indistinguishable. Every thread
+/// it needs (vaxis's reader, the SIGWINCH sigwait) is the caller's to have
+/// started, which is the whole difference between the two entries.
+fn attachLoop(a: *Attach) AttachEnd {
+ while (true) {
+ const link = a.client.wait(detached_client.poll_ms);
+ // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in
+ // the same call that read the last bytes, and the last bytes are the
+ // session's `quit`: `fill` appends every chunk and only then sees the
+ // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly
+ // this reason, and honouring that means draining what arrived before
+ // deciding the link is what ended us — otherwise an ordinary `Kill`
+ // exits one frontend 0 (it got the quit alone) and whichever frontend
+ // was in the same poll round nonzero, which is what the first run of
+ // this loop actually did.
+ while (true) {
+ const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break;
+ if (a.handle(msg)) |end| return end;
+ }
+ link catch |err| return .{ .lost = err };
+ // The terminal, drained the way `Shell.waitInput` drains it and for its
+ // reason: a wheel flick is one batch rather than fifty round trips, and
+ // a paste in flight keeps draining without a message per character.
+ var batch: usize = 0;
+ while (a.in_paste or batch < 64) {
+ // Propagated rather than swallowed, unlike `Shell.waitInput`'s
+ // identical drain: there the blocking `nextEvent` above it is what
+ // notices a dead event source, and here there is no blocking read
+ // to notice with.
+ const ev = (a.loop.tryEvent() catch |err| return .{ .lost = err }) orelse break;
+ batch += 1;
+ if (a.apply(ev)) |end| return end;
+ }
+ a.enableCaps();
+ if (a.dirty) a.paint();
+ }
+}
-/// The whole `--attach` session: connect, then one loop over the two event
-/// sources until the session, the link or the terminal ends it. The terminal is
-/// already raw, on the alt screen and reporting the mouse — `run` did that, and
-/// `run`'s defers undo it, which is what makes an attach leave a terminal in
-/// exactly the state an ordinary exit does.
-fn attachSession(init: std.process.Init, opts: pardes.Options, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd {
+/// The whole `--attach` run: connect, then `attachLoop` until the session, the
+/// link or the terminal ends it. The terminal is already raw, on the alt screen
+/// and reporting the mouse — `run` did that, and `run`'s defers undo it, which
+/// is what makes an attach leave a terminal in exactly the state an ordinary
+/// exit does.
+///
+/// No `Options` reaches here any more. Every field of it describes a core, and
+/// the last two this frontend read went with the work that read them: the
+/// config directory served a `dump_themes` the daemon now does itself, and
+/// `nested` gated a listener for children this process no longer has.
+fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd {
const io = init.io;
const gpa = init.gpa;
@@ -2244,16 +2278,18 @@ fn attachSession(init: std.process.Init, opts: pardes.Options, name: []const u8,
if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize };
vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err };
- var client = detached_client.Client.open(gpa, name, ws.cols, ws.rows) catch |err| return switch (err) {
- error.NoSession, error.NoSessionPath => .{ .no_session = name },
- // The connect landed and the session hung up during the hello. That is
- // what a refusal AT ACCEPT TIME looks like from here: server.zig's
- // `refuseFd` writes six bytes and closes in the same pass, so the close
- // can beat our hello onto the socket and `open` never gets far enough
- // to read the reason. A refusal we do read arrives as `.refused` with
- // the reason in it.
- error.Closed => .rejected,
- else => .{ .lost = err },
+ // The SAME three steps the `Attach` builtin takes, through the same
+ // function, because the two entries drifted apart the last time they were
+ // written separately: `--attach` resolved a bare name and the builtin did
+ // not, so the documented `SPC s a` answered `NoSessionPath` at a session
+ // that was listening. `attempt` resolves, connects, and waits to be
+ // GREETED. This path could afford to meet a refusal inside the loop below
+ // — it has no local session to lose — but there is no second sequence to
+ // maintain, so it does not have its own.
+ var attempt = detached_client.attempt(gpa, name, ws.cols, ws.rows);
+ var client = switch (attempt) {
+ .greeted => |c| c,
+ else => return attemptEnd(&attempt, name),
};
// `detach` and not `deinit`: seven bytes that turn "the peer vanished" into
// "the peer left" in the session's log.
@@ -2262,105 +2298,35 @@ fn attachSession(init: std.process.Init, opts: pardes.Options, name: []const u8,
var loop: Loop = .init(io, tty, vx);
var paste_buf: std.Io.Writer.Allocating = .init(gpa);
defer paste_buf.deinit();
- // Private and complete before any fork, exactly as in `run`: the pane
- // shells this frontend is asked to spawn borrow these stable path buffers.
- var prompt_rcs = shell_bin.PromptRcs.init();
- defer prompt_rcs.deinit();
var a: Attach = .{
- .io = io,
.gpa = gpa,
.client = &client,
.loop = &loop,
.vx = vx,
.tty = tty,
- .prompt_rcs = &prompt_rcs,
.paste_buf = &paste_buf,
- .config_dir = opts.config_dir,
- .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1,
+ // Concrete here, unlike the builtin's path: `run` resolved a bare
+ // `--attach` to one name before it opened the terminal, and it outlives
+ // this call. So a `Detach` from a `--attach` frontend can say what to
+ // come back to.
+ .session = name,
};
- // Registered BEFORE `loop.stop()` below so LIFO runs it after: the reader
- // has to be joined before the queue is emptied, or a late post lands in a
- // queue nobody drains again and its bytes leak. `run`'s teardown has the
- // same shape and the same order, minus the workers this frontend never
- // starts.
- defer {
- for (&a.ptys) |*slot| if (slot.*) |*pt| {
- pt.reader.cancel(io) catch {};
- _ = libc.close(pt.file.handle);
- slot.* = null;
- };
- if (a.watch_task) |*t| {
- t.cancel(io) catch {};
- a.watch_task = null;
- }
- if (a.inotify_fd >= 0) {
- _ = libc.close(a.inotify_fd);
- a.inotify_fd = -1;
- }
- for (&a.watch_paths) |*slot| if (slot.*) |p| {
- gpa.free(p);
- slot.* = null;
- };
- while (loop.tryEvent() catch null) |ev| switch (ev) {
- .pty_read => |pr| gpa.free(pr.bytes),
- .command => |line| gpa.free(line),
- .paste => |b| gpa.free(@constCast(b)),
- else => {},
- };
- }
-
- // A pardes started inside one of THIS frontend's pane shells finds this
- // process as its outer instance — the shells are our children — so the
- // nested-instance listener belongs here and not in the session, which has
- // no children at all. The command line it accepts goes over the wire as an
- // `Event.command`, which is what `ClientTag.command` is for.
- const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
- defer nested.unlisten(sock_fd);
+ // The whole teardown this frontend owes, which is now one queue drain: no
+ // ptys, no watches, no workers, nothing forked. Registered BEFORE
+ // `loop.stop()` below so LIFO runs it after — vaxis's reader has to be
+ // joined before the queue is emptied, or a late post lands in a queue
+ // nobody drains again and its bytes leak.
+ defer drainAttachedQueue(&loop, gpa);
loop.start() catch |err| return .{ .lost = err };
defer loop.stop();
- // Both detached threads, for `run`'s reasons: sigwait and accept4 never
- // return, so an `io.concurrent` task around either would hang the teardown
- // that joins it.
+ // Detached rather than an `io.concurrent` task, for `run`'s reason: sigwait
+ // never returns, so a task around it would hang the teardown that joins it.
(std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach();
- if (sock_fd >= 0) (std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, &loop }) catch |err| return .{ .lost = err }).detach();
- if (a.inotify_fd >= 0) a.watch_task = io.concurrent(watchFiles, .{ io, a.inotify_fd, &loop }) catch null;
// Send the capability probes and do not wait on them; `Attach.enableCaps`
// resolves them on the loop. run() has the long version of why.
vx.queryTerminalSend(tty.writer()) catch {};
- while (true) {
- const link = a.client.wait(attach_poll_ms);
- // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in
- // the same call that read the last bytes, and the last bytes are the
- // session's `quit`: `fill` appends every chunk and only then sees the
- // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly
- // this reason, and honouring that means draining what arrived before
- // deciding the link is what ended us — otherwise an ordinary `Kill`
- // exits one frontend 0 (it got the quit alone) and whichever frontend
- // was in the same poll round nonzero, which is what the first run of
- // this loop actually did.
- while (true) {
- const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break;
- if (a.handle(msg)) |end| return end;
- }
- link catch |err| return .{ .lost = err };
- // The terminal, drained the way `Shell.waitInput` drains it and for its
- // reason: a wheel flick is one batch rather than fifty round trips, and
- // a paste in flight keeps draining without a message per character.
- var batch: usize = 0;
- while (a.in_paste or batch < 64) {
- // Propagated rather than swallowed, unlike `Shell.waitInput`'s
- // identical drain: there the blocking `nextEvent` above it is what
- // notices a dead event source, and here there is no blocking read
- // to notice with.
- const ev = (loop.tryEvent() catch |err| return .{ .lost = err }) orelse break;
- batch += 1;
- if (a.apply(ev)) |end| return end;
- }
- if (a.reloadWatched()) |end| return end;
- a.enableCaps();
- if (a.dirty) a.paint();
- }
+ return attachLoop(&a);
}