summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 13:27:46 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch)
tree803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/tty
parentfbc194068687e49a8490c85c9f1257a2f2bb9079 (diff)
downloadpardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz
pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig882
1 files changed, 792 insertions, 90 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index c5abf0fe..f8042d71 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -21,6 +21,13 @@ 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.
+const detached_client = @import("../detached/client.zig");
+const detached_server = @import("../detached/server.zig");
+const wire = @import("../detached/wire.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;
@@ -445,10 +452,29 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee
return null;
}
-pub fn run(init: std.process.Init, opts: pardes.Options) !void {
+/// `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.
+pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void {
const io = init.io;
const gpa = init.gpa;
+ // `--attach` only, and registered HERE — before the terminal is opened —
+ // for the LIFO: it must run after every deferred restore below. Why a
+ // frontend stopped is discovered deep inside the loop while the alt screen
+ // is still up, and anything written there is erased by the switch back to
+ // the main screen, which is the one screen a user would look at.
+ var attach_end: AttachEnd = .none;
+ defer attach_end.report(io);
+
+ // ...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.
+ 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;
@@ -495,6 +521,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// tty is still open — sends the disable off that flag.
try vx.setBracketedPaste(tty.writer(), true);
+ // `--attach`: this process has a terminal and NO core. Everything above is
+ // the terminal, which an attached frontend needs exactly as much as a whole
+ // session does; everything below is the core, which lives in the detached
+ // process. The branch is here so both leave by the same door — an attach
+ // has to restore cooked mode, the main screen and the mouse the way an
+ // 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);
+ return;
+ }
+
pardes.image.start(io, allocs.image);
if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf);
pardes.syntax.start(allocs.tree_sitter);
@@ -796,11 +834,6 @@ const Shell = struct {
/// the tracks the frame in flight was composed from
tracks: []const pardes.panel_animation.Track = &.{},
- /// 4 MiB ceiling, past which the tail is dropped rather than grown into. A
- /// paste that large is a mis-click on a file, not an edit, and the core
- /// would have to hold the whole of it as one undo entry.
- const max_paste_bytes: usize = 4 << 20;
-
fn of(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
}
@@ -924,61 +957,13 @@ const Shell = struct {
core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
},
.key_press => |key| if (s.in_paste) {
- // Between the markers a key is DATA, never a command. Same
- // two inputs as the dispatch below, so a pasted character
- // is exactly the character the core would have been given.
- const text = key.text orelse "";
- const cp = mapKey(effCp(key));
- const bytes: []const u8 = if (text.len > 0)
- text
- else if (cp == pardes.Key.tab)
- "\t"
- else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j'))
- // vaxis gives control bytes no text at all: a line
- // break inside a paste reaches the ground parser as a
- // bare CR (-> Key.enter) or, from a terminal that does
- // not translate them, a bare LF — which that parser
- // reports as ctrl+j. Nothing in here is a real
- // keypress, so both of them are just a newline.
- "\n"
- else
- // arrows, F-keys, a stray escape: noise a paste has no
- // business carrying, dropped rather than smuggled in.
- "";
+ const bytes = pasteBytes(key);
const room = max_paste_bytes -| s.paste_buf.written().len;
s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {};
- } else core.update(.{ .key = .{
- .cp = mapKey(effCp(key)),
- .text = key.text orelse "",
- .ctrl = key.mods.ctrl,
- .alt = key.mods.alt,
- .shift = key.mods.shift,
- } }),
+ } else core.update(keyEvent(key)),
.mouse => |m| {
const pdf_before = nativePdfWheelTarget(core, m);
- const button: ?pardes.Mouse.Button = switch (m.button) {
- .left => .left,
- .middle => .middle,
- .right => .right,
- .wheel_up => .wheel_up,
- .wheel_down => .wheel_down,
- .wheel_left => .wheel_left,
- .wheel_right => .wheel_right,
- .none => .none, // button-less motion: hover tracking
- else => null,
- };
- if (button) |b| core.update(.{ .mouse = .{
- .button = b,
- .kind = switch (m.type) {
- .press => .press,
- .release => .release,
- .motion => .motion,
- .drag => .drag,
- },
- .col = @intCast(m.col),
- .row = @intCast(m.row),
- .ctrl = m.mods.ctrl,
- } });
+ if (mouseEvent(m)) |ev| core.update(ev);
if (pdf_before) |before| {
if (core.panes[before.pane]) |pane| {
if (pane.pdfPage()) |page| {
@@ -1108,19 +1093,7 @@ const Shell = struct {
) catch return;
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
- win.clear();
- var y: u16 = 0;
- while (y < surface.rows) : (y += 1) {
- var x: u16 = 0;
- while (x < surface.cols) : (x += 1) {
- const cell = surface.at(x, y);
- if (cell.default) continue;
- win.writeCell(x, y, .{
- .char = .{ .grapheme = cell.grapheme() },
- .style = vaxisStyle(cell.style),
- });
- }
- }
+ paintCells(win, surface.cells, surface.cols, surface.rows);
tz_cells.end();
// Pixel attachments (kitty graphics): transmit once per pixel
// generation, then re-place every frame (placements aren't
@@ -1189,11 +1162,7 @@ const Shell = struct {
vx.freeImage(s.tty.writer(), removed.value.id);
if (stale_len < stale.len) break;
}
- if (surface.cursor) |cur| {
- win.showCursor(cur.x, cur.y);
- // insert = beam, everything else = the terminal's default shape
- win.setCursorShape(if (cur.bar) .beam else .default);
- }
+ if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
const tz_render = tracy.zone(@src(), "vx.render");
vx.render(s.tty.writer()) catch {};
tz_render.end();
@@ -1267,14 +1236,7 @@ const Shell = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(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 (!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
@@ -1296,10 +1258,7 @@ const Shell = struct {
const s = of(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 (!writeFileBytes(path, bytes)) return;
s.core.setLastDump(path);
}
@@ -1544,7 +1503,15 @@ fn wakeFs(ctx: ?*anyopaque) void {
_ = loop.tryPostEvent(.fs_ready) catch {};
}
-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 } {
+/// `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
@@ -1554,7 +1521,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro
// 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.panes[pane]) |pn| pn.serial else 0);
+ 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) {
@@ -1568,7 +1535,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
- if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
+ 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 };
}
@@ -1621,6 +1588,125 @@ fn mapKey(cp: u21) u21 {
};
}
+/// 4 MiB ceiling, past which the tail is dropped rather than grown into. A
+/// paste that large is a mis-click on a file, not an edit, and the core would
+/// have to hold the whole of it as one undo entry. File scope rather than a
+/// `Shell` decl because the `--attach` frontend accumulates the same bursts
+/// against the same ceiling, and wire.zig cites this name as THE cap.
+const max_paste_bytes: usize = 4 << 20;
+
+/// One key press between the bracketed-paste markers, as the bytes it means.
+/// A key in there is DATA, never a command, and the two callers — the
+/// in-process host and the `--attach` frontend — must agree exactly, because
+/// what they produce is compared against what a terminal's own paste would
+/// have delivered.
+fn pasteBytes(key: vaxis.Key) []const u8 {
+ const text = key.text orelse "";
+ if (text.len > 0) return text;
+ const cp = mapKey(effCp(key));
+ if (cp == pardes.Key.tab) return "\t";
+ // vaxis gives control bytes no text at all: a line break inside a paste
+ // reaches the ground parser as a bare CR (-> Key.enter) or, from a
+ // terminal that does not translate them, a bare LF — which that parser
+ // reports as ctrl+j. Nothing in here is a real keypress, so both of them
+ // are just a newline.
+ if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n";
+ // arrows, F-keys, a stray escape: noise a paste has no business carrying,
+ // dropped rather than smuggled in.
+ return "";
+}
+
+/// ...and one ordinary key press as the core's event. Shared for the reason
+/// above: a keystroke must mean the same thing whether the core is in this
+/// process or on the other end of a socket.
+fn keyEvent(key: vaxis.Key) pardes.Event {
+ return .{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ .shift = key.mods.shift,
+ } };
+}
+
+/// ...and one mouse report. Null for a button this vocabulary has no name for
+/// (vaxis reports more of them than the core has), which is a report to drop
+/// rather than a press to invent.
+fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
+ const button: pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .wheel_left => .wheel_left,
+ .wheel_right => .wheel_right,
+ .none => .none, // button-less motion: hover tracking
+ else => return null,
+ };
+ return .{ .mouse = .{
+ .button = button,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ .ctrl = m.mods.ctrl,
+ } };
+}
+
+/// THE cell walk: canonical cells -> vaxis, cell for cell, with no
+/// interpretation. One walk and two callers, because a `frame` off the
+/// detached wire IS a `Surface`'s cells — wire.zig carries the grid and
+/// deliberately does not carry the two halves `Shell.present` adds around this
+/// (the kitty attachments, which have no encoding, and the panel transition,
+/// which the session composes before it sends). A second walk here would be
+/// two renderers for one canonical interface, and the interface is the thing
+/// this editor is.
+fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
+ win.clear();
+ var y: u16 = 0;
+ while (y < rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < cols) : (x += 1) {
+ const cell = &cells[@as(usize, y) * cols + x];
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+}
+
+fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void {
+ win.showCursor(x, y);
+ // insert = beam, everything else = the terminal's default shape
+ 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),
@@ -1662,3 +1748,619 @@ fn writeFd(fd: c_int, data: []const u8) void {
off += @intCast(n);
}
}
+
+// ---------------------------------------------------------------------------
+// --attach: a terminal, a socket, and no core
+// ---------------------------------------------------------------------------
+
+/// Why an `--attach` frontend stopped, and where its exit status comes from.
+/// A VALUE rather than a message printed where it is discovered: at that point
+/// the alt screen is still up and everything written to it is erased by the
+/// restore a moment later. `run` registers `report` BEFORE it opens the
+/// terminal, so LIFO runs it last — on a cooked main screen, which is the one
+/// screen a person would go looking at.
+const AttachEnd = union(enum) {
+ /// not an `--attach` run at all, or the session said `quit`: exit 0
+ none,
+ /// `--attach=<name>` and nothing is listening under it
+ no_session: []const u8,
+ /// bare `--attach` with no session to mean...
+ nothing_detached,
+ /// ...or more than one, which is a choice and not ours to make
+ ambiguous: usize,
+ /// the session hung up on the connect and said why
+ 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`)
+ rejected,
+
+ /// 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;
+ const text: []const u8 = switch (e) {
+ .none => return,
+ .no_session => |name| std.fmt.bufPrint(
+ &buf,
+ "pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n",
+ .{ name, name },
+ ) catch "pardes: no detached session under that name\n",
+ .nothing_detached => "pardes: no detached session is running (start one with `pardes --detach`)\n",
+ .ambiguous => |n| std.fmt.bufPrint(
+ &buf,
+ "pardes: {d} detached sessions are running; say which with --attach=<name>\n",
+ .{n},
+ ) catch "pardes: several detached sessions are running; say which with --attach=<name>\n",
+ .refused => |why| switch (why) {
+ .version => "pardes: that session speaks a different wire version — it is another build of pardes\n",
+ .full => "pardes: that session already has every frontend slot taken\n",
+ .quitting => "pardes: that session is ending\n",
+ },
+ .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",
+ };
+ std.Io.File.stderr().writeStreamingAll(io, text) catch {};
+ 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;
+ };
+ 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.
+///
+/// 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`.
+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,
+ 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
+ /// them is on the screen.
+ dirty: bool = false,
+
+ /// One event onto the wire. Non-null ends this frontend.
+ 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.
+ 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`.
+ fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd {
+ switch (msg) {
+ // Already applied to the client's slot and geometry; the full frame
+ // the session promises a fresh attach is the next thing to arrive.
+ .welcome => {},
+ .refuse => |why| return .{ .refused = why },
+ // Applied too — `grid` and `cursor` are current by the time this
+ // 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 {};
+ },
+ // 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 {},
+ .open_link => |url| look.openLink(url),
+ }
+ return null;
+ }
+
+ /// One vaxis event, translated onto the wire. Non-null ends the loop.
+ fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd {
+ switch (event) {
+ // Nothing posts these here, and each absence has a reason. `tick`
+ // 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 => {},
+ .quit => return .none,
+ .focus_in => {},
+ .focus_out => return a.send(.pointer_leave),
+ .winsize => |ws| {
+ a.vx.resize(a.gpa, a.tty.writer(), ws) catch {};
+ // Repaint from the frame already in hand: vaxis has just thrown
+ // its shadow grid away, and the SESSION grid may not move at
+ // all — it is the smallest common one and another frontend may
+ // be the small one (client.zig GEOMETRY).
+ 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;
+ a.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {};
+ } else return a.send(keyEvent(key)),
+ .mouse => |m| if (mouseEvent(m)) |ev| return a.send(ev),
+ .paste => |bytes| {
+ defer a.gpa.free(@constCast(bytes));
+ return a.send(.{ .paste = bytes });
+ },
+ .paste_start => {
+ a.in_paste = true;
+ a.paste_buf.clearRetainingCapacity();
+ },
+ .paste_end => {
+ a.in_paste = false;
+ defer a.paste_buf.clearRetainingCapacity();
+ // ONE message for the whole paste, exactly as the in-process
+ // host makes it one `update`.
+ const pasted = a.paste_buf.written();
+ if (pasted.len > 0) return a.send(.{ .paste = pasted });
+ },
+ .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
+ /// the tty. No `native_images` here — this wire carries no attachments.
+ fn enableCaps(a: *Attach) void {
+ if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return;
+ a.caps_pending = false;
+ a.vx.enableDetectedFeatures(a.tty.writer()) catch {};
+ // Earlier frames were drawn under the pre-handshake caps, and vaxis's
+ // shadow grid has to be re-established under the new ones or it keeps
+ // skipping cells it thinks are current.
+ a.vx.queueRefresh();
+ a.dirty = true;
+ }
+
+ fn paint(a: *Attach) void {
+ a.dirty = false;
+ const win = a.vx.window();
+ // The session grid can be smaller than this window; `paintCells` clears
+ // first, so the surplus is the terminal's own default cell rather than
+ // whatever was there a frame ago.
+ paintCells(win, a.client.grid.items, a.client.cols, a.client.rows);
+ if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
+ a.vx.render(a.tty.writer()) catch {};
+ }
+};
+
+/// 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;
+
+/// 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 {
+ const io = init.io;
+ const gpa = init.gpa;
+
+ // The hello carries this window, so the size has to be real before it goes
+ // out: a session told 80x24 by a 200x50 terminal reflows every pane twice,
+ // once now and once on the first SIGWINCH. `vx.resize` here rather than
+ // waiting for the loop's first event for the same reason — the first frame
+ // may arrive before any terminal event does, and it has to have somewhere
+ // to be painted.
+ const ws = tty.getWinsize() catch |err| return .{ .lost = err };
+ 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 },
+ };
+ // `detach` and not `deinit`: seven bytes that turn "the peer vanished" into
+ // "the peer left" in the session's log.
+ defer client.detach();
+
+ 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,
+ };
+ // 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);
+
+ 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.
+ (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();
+ }
+}