summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
diff options
context:
space:
mode:
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);
}