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