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