diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 64 | ||||
| -rw-r--r-- | src/config.zig | 20 | ||||
| -rw-r--r-- | src/detached/client.zig | 405 | ||||
| -rw-r--r-- | src/detached/server.zig | 1169 | ||||
| -rw-r--r-- | src/detached/wire.zig | 464 | ||||
| -rw-r--r-- | src/gui/gui.zig | 976 | ||||
| -rw-r--r-- | src/host.zig | 10 | ||||
| -rw-r--r-- | src/host_io.zig | 178 | ||||
| -rw-r--r-- | src/limits.zig | 15 | ||||
| -rw-r--r-- | src/macos.zig | 57 | ||||
| -rw-r--r-- | src/main.zig | 59 | ||||
| -rw-r--r-- | src/nested.zig | 12 | ||||
| -rw-r--r-- | src/pardes.zig | 464 | ||||
| -rw-r--r-- | src/pdf_pane_integration_test.zig | 42 | ||||
| -rw-r--r-- | src/term_pane.zig | 1632 | ||||
| -rw-r--r-- | src/tty/tty.zig | 1036 |
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); } |
