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.zig1362
1 files changed, 420 insertions, 942 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index fb0a5b8e..1f11c535 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1,12 +1,4 @@
-//! The terminal shell: owns the event loop and all IO. Translates vaxis
-//! 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 filesystem = @import("../fs.zig");
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
@@ -15,19 +7,11 @@ const vaxis = @import("vaxis");
const pardes = @import("../pardes.zig");
const tracy = @import("../tracy.zig");
const look = @import("../look.zig");
-const shell_bin = @import("../shell_bin.zig");
-const message = @import("../message.zig");
+const message = pardes.Pardes.Message;
const file_watch = @import("../file_watch.zig");
-const user_config = @import("../user_config.zig");
const selection_pipe = @import("../selection_pipe.zig");
-const nested = @import("../nested.zig");
-const fuse = @import("../fuse.zig");
-const fs_service = @import("../fs_service.zig");
+const ninep_io = @import("../9p_io.zig");
const panel_compositor = @import("panel_compositor.zig");
-const host_api = @import("../host.zig");
-// 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");
@@ -35,7 +19,6 @@ const host_io = @import("../host_io.zig");
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
-// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize)
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
pub const Command = struct {
@@ -44,54 +27,231 @@ pub const Command = struct {
quit,
tick,
key_press: vaxis.Key,
- pty_read: struct { id: usize, bytes: []u8 },
+ pty_read: struct { id: usize, gen: u32, bytes: []u8 },
pty_eof: struct { id: usize, gen: u32 },
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
- /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY
- /// cannot report a literal pointer crossing its character grid, so
- /// losing terminal focus is its only reliable pointer-leave signal.
focus_in,
focus_out,
paste: []const u8,
- /// The bracketed-paste brackets. vaxis posts them ONLY because this
- /// union declares fields with these exact names — its Loop gates every
- /// event on `@hasField` — and the pasted bytes themselves arrive
- /// BETWEEN them as ordinary key presses, which the loop accumulates
- /// into one `.paste` above instead of running as commands.
paste_start,
paste_end,
- /// a language query finished on a worker; rows are lsp-domain-owned
- lsp_done: struct { id: u32, rows: []u8 },
- /// a language SERVER changed state (spawned, indexing, exited) — the
- /// client's reader thread narrates and this lands it on the message
- /// row; text is lsp-domain-owned
+ lsp_done: struct { id: u32, rows: ?[]u8 },
lsp_status: []u8,
- /// a selection-filter worker finished; every stdout is gpa-owned
pipe_done: selection_pipe.Response,
- /// something happened in a watched directory (see watchFiles)
files_changed,
- /// a pardes launched inside this one sent us a builtin command line
- /// (see lookServer); gpa-owned, like pty_read
- command: []u8,
- /// `--fs`: the /dev/fuse descriptor has requests on it. Carries
- /// nothing and is applied as a no-op — its whole job is to end the
- /// blocking `nextEvent`, because the drain itself lives in pollFrame
- /// beside the file-watch reload. Same shape and same reason as
- /// `files_changed` above, and posted from two places: the poll thread
- /// when the kernel makes the descriptor readable, and pollFrame itself
- /// when a batch hit its cap with requests still pending.
fs_ready,
} = .nop;
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
-/// The shared snapshot/worker pair every native shell uses. This file used to
-/// carry its own `LspJob` and gui.zig carried a copy of it; the copies said so.
-const lsp_host = @import("../lsp_host.zig");
+fn startInput(loop: *Loop, cache: *vaxis.GraphemeCache) !void {
+ if (comptime builtin.os.tag == .windows) return loop.start();
+ if (loop.thread != null) return;
+ loop.thread = try loop.io.concurrent(inputThread, .{ loop, cache });
+}
+
+fn stopInput(loop: *Loop) void {
+ if (comptime builtin.os.tag == .windows) return loop.stop();
+ if (loop.thread) |*thread| {
+ thread.cancel(loop.io);
+ loop.thread = null;
+ }
+}
+
+fn inputThread(loop: *Loop, cache: *vaxis.GraphemeCache) void {
+ inputReader(loop, loop.tty, cache);
+}
+
+fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void {
+ readInput(loop, tty, cache) catch |err| {
+ if (err == error.Canceled) return;
+ std.log.err("terminal input: {s}", .{@errorName(err)});
+ };
+ loop.postEvent(.quit) catch {};
+}
+
+fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
+ try loop.postEvent(.{ .winsize = try tty.getWinsize() });
+ var parser: vaxis.Parser = .{};
+ var buf: [1024]u8 = undefined;
+ var carried: usize = 0;
+ while (!loop.should_quit) {
+ if (carried == buf.len) return error.InputSequenceTooLong;
+ const received = try tty.read(buf[carried..]);
+ if (received == 0) return;
+ const end = carried + received;
+ var parse_end = end;
+ var lead = end;
+ while (lead > 0 and buf[lead - 1] & 0xc0 == 0x80) lead -= 1;
+ if (lead > 0) {
+ const scalar_len = std.unicode.utf8ByteSequenceLength(buf[lead - 1]) catch 1;
+ if (scalar_len > end - (lead - 1)) parse_end = lead - 1;
+ }
+ var consumed: usize = 0;
+ while (consumed < parse_end) {
+ const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator);
+ if (result.n == 0) break;
+ consumed += result.n;
+ if (result.event) |event|
+ vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| {
+ if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste));
+ return err;
+ };
+ }
+ carried = end - consumed;
+ std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]);
+ }
+}
+
+test "terminal input preserves fragmented keys queries paste and text after EOF" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const io = std.testing.io;
+ var env = try std.testing.environ.createMap(gpa);
+ defer env.deinit();
+ var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa });
+ var output: std.Io.Writer.Allocating = .init(gpa);
+ defer output.deinit();
+ defer vx.deinit(gpa, &output.writer);
+ vx.queries_done.store(false, .unordered);
+ var tty: vaxis.Tty = undefined;
+ var loop: Loop = .init(io, &tty, &vx);
+ var cache: vaxis.GraphemeCache = .{};
+ const Reader = struct {
+ parts: []const []const u8,
+ next: usize = 0,
+ fn getWinsize(_: *@This()) !vaxis.Winsize {
+ return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 };
+ }
+ fn read(self: *@This(), buf: []u8) !usize {
+ if (self.next == self.parts.len) return 0;
+ const part = self.parts[self.next];
+ self.next += 1;
+ @memcpy(buf[0..part.len], part);
+ return part.len;
+ }
+ };
+ var reader: Reader = .{ .parts = &.{
+ "plain \x1b[?62;", "4c\x1b[", "A\x1b[200", "~caf\xc3", "\xa9 \xe7", "\x95",
+ "\x8c \xf0\x9f", "\x98\x80", "\x1b[201", "~\x1b]52;c;Y2", "xpcA==\x07tail", "\x1b",
+ } };
+ try readInput(&loop, &reader, &cache);
+ try std.testing.expect(vx.queries_done.load(.unordered));
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(gpa);
+ var resized = false;
+ var up = false;
+ var in_paste = false;
+ var pasted = false;
+ var clipboard = false;
+ var escape = false;
+ while (try loop.tryEvent()) |event| switch (event) {
+ .winsize => |size| {
+ try std.testing.expect(!resized);
+ resized = true;
+ try std.testing.expectEqual(@as(u16, 80), size.cols);
+ },
+ .key_press => |key| {
+ try std.testing.expect(resized);
+ if (key.codepoint == vaxis.Key.up) {
+ up = true;
+ } else if (key.codepoint == vaxis.Key.escape) {
+ escape = true;
+ } else if (key.text) |bytes| {
+ if (in_paste) try std.testing.expect(up);
+ try text.appendSlice(gpa, bytes);
+ } else return error.UnexpectedInputKey;
+ },
+ .paste_start => {
+ try std.testing.expect(up and !in_paste);
+ in_paste = true;
+ },
+ .paste_end => {
+ try std.testing.expect(in_paste);
+ in_paste = false;
+ pasted = true;
+ },
+ .paste => |bytes| {
+ defer gpa.free(@constCast(bytes));
+ try std.testing.expect(pasted and !in_paste);
+ try std.testing.expectEqualStrings("clip", bytes);
+ clipboard = true;
+ },
+ else => return error.UnexpectedInputEvent,
+ };
+ try std.testing.expectEqualStrings("plain café 界 😀tail", text.items);
+ try std.testing.expect(resized and up and pasted and clipboard and escape);
+}
+
+test "terminal input cancellation joins blocked reads and queued EOF" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const io = std.testing.io;
+ var env = try std.testing.environ.createMap(gpa);
+ defer env.deinit();
+ var vx = try vaxis.init(io, gpa, &env, .{});
+ var output: std.Io.Writer.Allocating = .init(gpa);
+ defer output.deinit();
+ defer vx.deinit(gpa, &output.writer);
+ const Reader = struct {
+ file: std.Io.File,
+ eof: bool,
+ entered: std.Io.Event = .unset,
+ release: std.Io.Event = .unset,
+ returned: std.Io.Event = .unset,
+ fn getWinsize(_: *@This()) !vaxis.Winsize {
+ return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 };
+ }
+ fn read(self: *@This(), buf: []u8) !usize {
+ self.entered.set(std.testing.io);
+ if (self.eof) {
+ try self.release.wait(std.testing.io);
+ self.returned.set(std.testing.io);
+ return 0;
+ }
+ return self.file.readStreaming(std.testing.io, &.{buf});
+ }
+ fn run(loop: *Loop, reader: *@This(), cache: *vaxis.GraphemeCache) void {
+ inputReader(loop, reader, cache);
+ }
+ };
+ const Case = enum { blocked_read, cancel_eof, deliver_eof };
+ for (std.enums.values(Case)) |case| {
+ const eof = case != .blocked_read;
+ var fds: [2]c_int = undefined;
+ if (libc.pipe(&fds) != 0) return error.PipeFailed;
+ defer _ = libc.close(fds[0]);
+ defer _ = libc.close(fds[1]);
+ var reader: Reader = .{ .file = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }, .eof = eof };
+ var tty: vaxis.Tty = undefined;
+ var loop: Loop = .init(io, &tty, &vx);
+ var cache: vaxis.GraphemeCache = .{};
+ loop.thread = try io.concurrent(Reader.run, .{ &loop, &reader, &cache });
+ defer stopInput(&loop);
+ try std.testing.expectEqual(.winsize, std.meta.activeTag(try loop.nextEvent()));
+ try reader.entered.wait(io);
+ if (eof) {
+ for (0..512) |_| try loop.postEvent(.nop);
+ reader.release.set(io);
+ try reader.returned.wait(io);
+ }
+ if (case == .deliver_eof) {
+ try std.testing.expectEqual(.nop, std.meta.activeTag(try loop.nextEvent()));
+ loop.thread.?.await(io);
+ }
+ stopInput(&loop);
+ try std.testing.expect(loop.thread == null);
+ var count: usize = 0;
+ while (try loop.tryEvent()) |event| {
+ const expected: std.meta.Tag(@TypeOf(Command.value)) = if (case == .deliver_eof and count == 511) .quit else .nop;
+ try std.testing.expectEqual(expected, std.meta.activeTag(event));
+ count += 1;
+ }
+ try std.testing.expectEqual(@as(usize, if (eof) 512 else 0), count);
+ }
+}
-/// The pipe in-flight set moved to `selection_pipe.Tasks`, beside the Job it
-/// tracks: gui.zig carried this same table verbatim.
const PipeTask = selection_pipe.Tasks.Task;
const PipeTasks = selection_pipe.Tasks;
@@ -125,9 +285,6 @@ fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u
} });
}
-/// Translate backend-neutral source/destination pixels into Kitty's source
-/// crop plus cell-sized placement. Kitty can specify only one scaled axis
-/// without distorting the image; the terminal derives the other axis.
fn kittyPlacement(
place: pardes.ImagePlace,
screen_cols: u16,
@@ -151,13 +308,6 @@ fn kittyPlacement(
place.native.pan_y,
) orelse return null;
- // Kitty has a top-left pixel offset but no destination bottom clip.
- // Its missing c/r axis is rounded up to whole terminal cells, so a
- // clipped fragment shorter than one row cannot be represented without
- // painting the following theme gap. Conservatively keep only whole
- // rows contained by geometry.dst and trim the source crop to the same
- // scale. Cached page pixels remain unchanged; an unrepresentable tail
- // is simply left as theme background.
const safe_rows_u32 = geometry.dst.h / cell_h;
if (safe_rows_u32 == 0) return null;
const safe_pixel_h = safe_rows_u32 * cell_h;
@@ -202,8 +352,6 @@ fn kittyPlacement(
}
if (declared_rows == 0 or declared_rows > safe_rows_u32) return null;
- // Every Kitty protocol field is u16. Reject an attachment which the
- // wire format cannot represent instead of truncating it.
const src_x = std.math.cast(u16, geometry.src.x) orelse return null;
const src_y = std.math.cast(u16, geometry.src.y) orelse return null;
const src_w = std.math.cast(u16, geometry.src.w) orelse return null;
@@ -287,8 +435,6 @@ test "Kitty PDF fragments never declare pixels beyond their clipped bottom" {
try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <=
height.native.geometry.?.dst.h);
- // There is no honest APC for less than one physical row: omitting it is
- // preferable to painting three pixels of the following theme gap.
height.native.geometry.?.dst.h = 13;
try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null);
}
@@ -304,8 +450,6 @@ test "host watch closes initial race and reloads rename-over PDF while idle" {
const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa);
defer gpa.free(replacement);
try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original });
- // Prepare both editor-style temporary inodes before marking the directory
- // so the only post-arm wake below is the second rename.
try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement });
try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original });
var path_buf: [256]u8 = undefined;
@@ -328,15 +472,12 @@ test "host watch closes initial race and reloads rename-over PDF while idle" {
defer core.deinit();
try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count);
- // The core opened the three-page inode, but the host has not drained its
- // watch effect yet. Replace it now: install-then-reconcile must discover
- // the one-page document even though no source existed for this first edge.
try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io);
var armed = false;
while (core.nextEffect()) |effect| switch (effect) {
.watch => |watch| if (watch.pane == 0 and watch.on) {
armed = true;
- try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true));
+ try std.testing.expect(!file_watch.applyEffect(core, io, fd, &watches, 0, true, watch.mode));
},
else => {},
};
@@ -346,8 +487,6 @@ test "host watch closes initial race and reloads rename-over PDF while idle" {
try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io);
try std.testing.expect(file_watch.drain(fd));
- // This is the same pass the watcher thread schedules; no key, mouse, or
- // synthetic core file_changed event participates in the transaction.
try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches));
const pane = core.panes[0].?;
try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count);
@@ -382,11 +521,6 @@ fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey)
const PdfWheelTarget = struct { pane: usize, page: usize };
-/// A native PDF's page geometry is invalid between `setPdfPage` and the next
-/// render. Remember the page under a vertical wheel press so the input batch
-/// can stop exactly when that press crosses a page boundary. The following
-/// queued wheel report then sees the newly rastered page instead of treating
-/// missing geometry as another page-wise fallback.
fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWheelTarget {
if (comptime !pardes.pdf_enabled) return null;
if (!core.native_images or mouse.type != .press or
@@ -405,37 +539,17 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee
return null;
}
-/// `attach` is `--attach[=<name>]`: empty means "the session there is" (see
-/// `detached_client.resolve`). It is a parameter rather than an `Options` field
-/// because it says nothing to the core — this process does not have one when
-/// it is set.
pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void {
const io = init.io;
const gpa = init.gpa;
- // `--attach` only, and registered HERE — before the terminal is opened —
- // for the LIFO: it must run after every deferred restore below. Why a
- // frontend stopped is discovered deep inside the loop while the alt screen
- // is still up, and anything written there is erased by the switch back to
- // the main screen, which is the one screen a user would look at.
var attach_end: AttachEnd = .none;
defer attach_end.report(io);
- // ...and resolved before the terminal too, for the same reason turned the
- // other way: "no session called work" is a launch that never started, and
- // flashing the alt screen up and straight back down to say so is worse
- // than never entering it. 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| 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;
@@ -446,16 +560,6 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
},
};
- // 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
- // thread blocked inside an Io.Threaded syscall region (pty readers in
- // read(2), the main thread parked in queue.pop) a contended lock re-enters
- // the Io machinery and Syscall.start hits `unreachable` — panic, then the
- // panic-time terminal restore used to write through the same Io and
- // recurse until stack overflow. Reproduced by resizing the outer terminal
- // (e.g. a font-size change) while shells run. Block it here, before any
- // thread exists (threads inherit the mask, so vaxis's handler never
- // fires), and take it synchronously on the sigwait thread below instead.
var winch_set = posix.sigemptyset();
posix.sigaddset(&winch_set, posix.SIG.WINCH);
posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null);
@@ -467,37 +571,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
defer vx.deinit(gpa, tty.writer());
try vx.enterAltScreen(tty.writer());
defer vx.exitAltScreen(tty.writer()) catch {};
- // requests 1002;1003;1004;1006 (cell-coordinate SGR; called pre-query, so
- // vaxis never upgrades to 1016 pixel mode). Note: ghostty's GTK apprt drops
- // middle press+release BEFORE mouse reporting when the desktop sets
- // gtk-enable-primary-paste=false — no mode we request can surface middle
- // clicks there (see test/snapshots/ghostty-mid.snap).
try vx.setMouseMode(tty.writer(), true);
- // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a
- // flood of key presses: plausible-looking in insert mode, and in normal
- // mode every pasted character runs as a command. With it the terminal
- // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them.
- // No defer to switch it back off, for the same reason the mouse modes
- // above have none: setBracketedPaste records state.bracketed_paste, and
- // vaxis's resetState — reached from the `defer vx.deinit` above, while the
- // tty is still open — sends the disable off that flag.
try vx.setBracketedPaste(tty.writer(), true);
- // `--attach`: this process has a terminal and NO core. Everything above is
- // the terminal, which an attached frontend needs exactly as much as a whole
- // session does; everything below is the core, which lives in the detached
- // process. The branch is here so both leave by the same door — an attach
- // has to restore cooked mode, the main screen and the mouse the way an
- // ordinary exit does, and sharing the deferred teardown is the only way to
- // guarantee that instead of asserting it.
if (attach != null) {
attach_end = attachSession(init, 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);
@@ -506,30 +587,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
var paste_buf: std.Io.Writer.Allocating = .init(gpa);
defer paste_buf.deinit();
var loop: Loop = .init(io, &tty, &vx);
+ var input_cache: vaxis.GraphemeCache = .{};
- // 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();
+ stopInput(&loop);
drainAttachedQueue(&loop, gpa);
};
- try localSession(init, opts, &tty, &vx, &loop, &kitty_handles, &paste_buf, &attached);
+ try localSession(init, opts, &tty, &vx, &loop, &input_cache, &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,
@@ -537,43 +603,19 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
.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,
+ input_cache: *vaxis.GraphemeCache,
kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image),
paste_buf: *std.Io.Writer.Allocating,
attached: *?detached_client.Client,
@@ -581,13 +623,12 @@ fn localSession(
const io = init.io;
const gpa = init.gpa;
- const allocs = pardes.allocators.init(gpa);
- defer pardes.allocators.deinit();
+ const allocs = pardes.memory.init(gpa);
+ defer pardes.memory.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);
@@ -600,37 +641,20 @@ fn localSession(
}
var core = if (options.load_path) |lp| blk: {
- const bytes = try look.readFile(gpa, lp);
+ const bytes = try filesystem.readFile(gpa, lp);
defer gpa.free(bytes);
break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes);
} else try pardes.Pardes.init(allocs.pardes, options);
defer core.deinit();
- // PATH, the bash banner and the prompt rc files, in the one order that
- // works. Children borrow only these stable in-struct path buffers.
- var prompt_rcs = shell_bin.prepareForFork();
+ var prompt_rcs = host_io.Shell.prepare();
defer prompt_rcs.deinit();
var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
- // `--fs`: mount before the initial spawns, because those shells are the
- // ones that need PARDES_FS in their environment, and before the first
- // frame, because a script racing startup must find panes that are already
- // there. Also before any thread of ours exists — the mount forks the
- // setuid fusermount3 helper, and forking from a multithreaded process is
- // the hazard this whole region is ordered around. Null covers both "no
- // --fs" and "--fs but the mount failed"; the second is reported on a
- // message row inside `start` and the session runs on regardless.
- //
- // The teardown answers every held request, aborts the connection,
- // unmounts and removes `<parent>/<pid>`. The PARENT (`.../pardes`) stays,
- // 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 and the handover; the ordinary exit unmounts at
- // the END OF THE LOOP instead, see there.
- defer if (fs) |f| f.deinit();
+ var fs = ninep_io.start(gpa, core);
+ defer if (fs) |f| f.deinit(gpa);
var sh: Shell = .{
.io = io,
@@ -644,57 +668,26 @@ fn localSession(
.kitty = kitty_handles,
.frame = &frame_arena,
.paste_buf = paste_buf,
- // One watcher 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. `false`: this host parks a thread
- // in it rather than polling it. -1 where there is no watcher to make:
- // watchPane goes quiet and the core simply never gets a file_changed.
.inotify_fd = file_watch.init(false),
.fs = fs,
};
- // The protocol client's reader threads narrate server state through this
- // sink from the moment it is set; posting is safe because the loop queue
- // outlives them all — and it is UNSET first thing in the defer below,
- // under the sink's own lock, so no reader can be mid-post when the queue
- // starts draining for teardown.
pardes.lsp.setStatusSink(&sh, lspStatusSink);
defer {
pardes.lsp.setStatusSink(null, null);
- // reap the reader tasks (cancel interrupts a blocked read) before
- // closing the masters — the runtime joins those threads on exit and a
- // reader stuck in read(2) would hang the process — then drain the
- // queue: leftover events own gpa bytes and would dump as leaks.
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
- // lands in a queue nobody empties again and the rows leak
if (sh.lsp_task) |*t| {
- t.cancel(io) catch {};
+ t.future.cancel(io) catch {};
sh.lsp_task = null;
}
sh.pipe_tasks.cancelAll(io);
- // same contract as the pty readers: the watcher has to be off the
- // descriptor before it is closed. `stop` is what releases a kqueue wait
- // (macos); `cancel` is what interrupts the blocking read (linux).
file_watch.stop(sh.inotify_fd);
if (sh.watch_task) |*t| {
t.cancel(io) catch {};
@@ -706,9 +699,8 @@ fn localSession(
}
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)),
- .lsp_done => |d| allocs.lsp.free(d.rows),
+ .lsp_done => |d| if (d.rows) |rows| allocs.lsp.free(rows),
.lsp_status => |text| allocs.lsp.free(text),
.pipe_done => |response_value| {
var response = response_value;
@@ -719,114 +711,44 @@ fn localSession(
}
const host = sh.host();
- // The socket a pardes launched inside this one connects to (nested.zig).
- // Declared AFTER the drain above so its teardown runs BEFORE it — the
- // listener thread must be out of the way before the queue is emptied.
- // --nested opted out of the whole mechanism, including being an outer
- // instance; so does any failure to bind, and then children simply open
- // their own session.
- const sock_fd: c_int = if (options.nested) -1 else nested.listen();
- defer nested.unlisten(sock_fd);
-
- // Perform the initial spawns BEFORE any worker thread exists: forkpty from
- // a multithreaded process can wedge the child before exec. `pump` installs
- // the host on every pass; this drain runs outside it, so install it here.
core.host = host;
while (core.nextEffect()) |effect| core.perform(effect);
- try loop.start();
- // ...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 startInput(loop, input_cache);
+ defer if (attached.* == null) stopInput(loop);
(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();
- // ...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);
- // 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
- // microseconds, `ssh localhost` in under 1ms, and any real link never.
- // Measured over sshd on :22 with the replies delayed to model the wire,
- // 2ms already loses at 5ms RTT and everything above.
- //
- // Losing it is worse than never probing, because vaxis splits detect from
- // enable and only detect respects the deadline. queryTerminal sets
- // queries_done the moment the futex times out, and the two replies vaxis
- // gates on that flag — explicit width and scaled text, both answered as a
- // cursor-position report — stop being recognised as probe replies and are
- // handed to US as shift-F3/alt-F3 keypresses. The replies it does NOT
- // gate (mode 2027, kitty keyboard/graphics, sgr-pixels) keep landing and
- // keep mutating vx.caps from the reader thread, long after
- // enableDetectedFeatures ran and declined to switch those modes on. So
- // over ssh the terminal sat in its default modes while caps claimed
- // otherwise — kitty keyboard was never actually pushed, ever. Raising the
- // timeout only moves the link speed at which that happens.
- //
- // Resolve it on the loop instead. DA1 is last in the probe string and
- // terminals answer in order, so when vaxis's reader flips queries_done
- // every earlier reply is already applied — no window left to miss at any
- // latency, and the enable lands before the next frame (see caps_pending
- // in pollFrame). A terminal that never answers keeps the defaults, which
- // is what the 2ms timeout produced anyway, and with no caps
- // enableDetectedFeatures writes no bytes — the snapshot goldens, where
- // nothing ever answers, do not move. Startup gets 2ms faster, not slower.
- //
- // ponytail: nothing wakes the loop for DA1 alone. In practice the reply
- // burst carries the mode-2048 size report too, which posts a winsize and
- // turns the loop; a terminal idle from boot that lands DA1 between two
- // parks keeps the defaults until the user's first keystroke (decoded
- // legacy, which vaxis handles). Ceiling accepted because nothing in the
- // render path reads the missing caps: pardes writes one codepoint per
- // cell plus an explicit blank spacer under a wide glyph, and vaxis's
- // Cell.width defaults to 1, so gwidth — the only consumer of
- // caps.unicode — is never called. If that ever changes, wake the loop on
- // vx.query_futex from a one-shot thread instead.
+ if (fs) |f| try f.wakeThread(loop, wakeFs);
try vx.queryTerminalSend(tty.writer());
- // 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 });
};
- // ...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;
- // 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.
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
- // their late eofs never touch the replay panes)
if (core.takeRestore()) |rp| blk: {
- const bytes = look.readFile(gpa, rp) catch break :blk;
+ const bytes = filesystem.readFile(gpa, rp) catch |err| {
+ core.reportError(core.active, "Restore", err);
+ break :blk;
+ };
defer gpa.free(bytes);
- var o = core.opts;
- o.cols = core.screen_w;
- o.rows = core.screen_h; // pre-size: dump panes never greet
- const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk;
- for (&sh.ptys, 0..) |*slot, pid| if (slot.*) |*pt| {
+ const nc = core.restore(bytes) catch |err| {
+ core.reportError(core.active, "Restore", err);
+ break :blk;
+ };
+ if (sh.lsp_task) |*task| {
+ task.future.cancel(io) catch {};
+ sh.lsp_task = null;
+ }
+ sh.pipe_tasks.cancelAll(io);
+ for (&sh.gens) |*generation| generation.* +%= 1;
+ for (&sh.ptys) |*slot| if (slot.*) |*pt| {
pt.reader.cancel(io) catch {};
_ = libc.close(pt.file.handle);
slot.* = null;
- sh.gens[pid] +%= 1;
};
- // the replay core's pane ids mean new things, and the dying core's
- // `watch off` effects go into a queue nobody drains — drop the lot
- // here. The new core emits its own `on`s as it builds its panes.
for (0..pardes.MAX_PANES) |wid| file_watch.watchPane(
sh.inotify_fd,
&sh.watches,
@@ -838,33 +760,14 @@ fn localSession(
_ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false);
clearNativeImages(kitty_handles, vx, tty);
nc.native_images = vx.caps.kitty_graphics;
+ if (fs) |f| f.reset(core);
core.deinit();
core = nc;
sh.core = nc;
if (comptime pardes.pdf_enabled) {
- // A restored core did not receive the terminal's earlier
- // winsize event. Reapply both the grid and physical cells
- // before its first PDF frame so pointer/pan geometry stays
- // identical to placement.
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) {
@@ -879,27 +782,13 @@ fn localSession(
}
}
}
- // 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
- // not return until the next keystroke — and a session that has decided to
- // 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. 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();
+ f.deinit(gpa);
fs = null;
sh.fs = null;
}
}
-/// 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,
@@ -910,119 +799,79 @@ fn clearNativeImages(
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`.
const Shell = struct {
io: std.Io,
gpa: std.mem.Allocator,
lsp_gpa: std.mem.Allocator,
- /// live core; Restore replaces it (see run)
core: *pardes.Pardes,
- prompt_rcs: *const shell_bin.PromptRcs,
+ prompt_rcs: *const host_io.Shell.PromptFiles,
loop: *Loop,
vx: *vaxis.Vaxis,
tty: *vaxis.Tty,
kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image),
frame: *std.heap.ArenaAllocator,
- /// Where a bracketed paste is assembled. It has to outlive one drain pass:
- /// the burst arrives over as many passes as the terminal takes to write
- /// it, and the markers are the only thing that says where it ends.
paste_buf: *std.Io.Writer.Allocating,
inotify_fd: c_int,
- /// acme's control filesystem for this session, or null when `--fs` was not
- /// given (or its mount failed). Owned by `run`, which mounts it before the
- /// first fork and tears it down on every path out.
- fs: ?*fuse.Fs = null,
+ fs: ?*ninep_io.Listener = null,
ptys: [pardes.MAX_PANES]?Pty = @splat(null),
- /// per-slot spawn generation: a reused pane id ignores the old shell's
- /// late pty_eof (which would otherwise close the NEW pty on that slot)
gens: [pardes.MAX_PANES]u32 = @splat(0),
watches: file_watch.Table = @splat(null),
watch_task: ?std.Io.Future(anyerror!void) = null,
- /// the single in-flight language query (see the lsp method)
- lsp_task: ?std.Io.Future(anyerror!void) = null,
- /// Selection filters may overlap: a second submit supersedes the first in
- /// core without synchronously canceling a possibly slow shell command.
+ lsp_task: ?host_io.Lsp.Task = null,
pipe_tasks: PipeTasks = .{},
- /// false during the pre-loop drain: no worker exists to answer to yet
threads_ok: bool = false,
caps_pending: bool = true,
check_files: bool = false,
in_paste: bool = false,
- /// the tracks the frame in flight was composed from
- tracks: []const pardes.panel_animation.Track = &.{},
+ tracks: []const pardes.layout.Track = &.{},
fn of(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
}
- fn host(s: *Shell) host_api.Host {
+ fn host(s: *Shell) host_io.Host {
return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable };
}
- /// An ISOLATED build (`zig build run-isolated`): the terminal this draws on
- /// and the keys it reads, and nothing else. Every other method stays null,
- /// so the core answers it itself — the embedded source filesystem, the
- /// in-process clipboard, silent ptys. Nothing is forked, nothing on disk is
- /// opened or written, and no clipboard leaves the process. The option is
- /// comptime, so the other vtable is not even built into that binary.
- const isolated_vtable: host_api.Host.VTable = .{
- .pull_wait_input = waitInput,
- .push_present = present,
- .push_post_present = postPresent,
+ const isolated_vtable: host_io.Host.VTable = .{
+ .wait_input = waitInput,
+ .present = present,
+ .post_present = postPresent,
};
- const vtable: host_api.Host.VTable = .{
- .pull_wait_input = waitInput,
- .push_present = present,
- .push_post_present = postPresent,
- .push_poll_frame = pollFrame,
- .push_spawn = spawn,
- .push_pty_write = ptyWrite,
- .push_pty_resize = ptyResize,
- .push_pty_signal = ptySignal,
- .pull_tty_taken = ttyTaken,
- .push_write_file = writeFile,
- .push_write_dump = writeDump,
- .push_watch_file = watchFile,
- .push_watch_theme = watchTheme,
- .push_dump_themes = dumpThemes,
- .push_set_clipboard = setClipboard,
- .pull_read_clipboard = readClipboard,
- .push_open_link = openLink,
- .pull_lsp = lsp,
- .pull_pipe = pipe,
- .push_fs_reply = fsReply,
+ const vtable: host_io.Host.VTable = .{
+ .wait_input = waitInput,
+ .present = present,
+ .post_present = postPresent,
+ .poll_frame = pollFrame,
+ .spawn = spawn,
+ .pty_write = ptyWrite,
+ .pty_resize = ptyResize,
+ .pty_signal = ptySignal,
+ .tty_taken = ttyTaken,
+ .write_file = writeFile,
+ .write_dump = writeDump,
+ .watch_file = watchFile,
+ .watch_theme = watchTheme,
+ .dump_themes = dumpThemes,
+ .set_clipboard = setClipboard,
+ .read_clipboard = readClipboard,
+ .open_link = openLink,
+ .lsp = lsp,
+ .pipe = pipe,
};
- // ---- input ------------------------------------------------------------
-
- /// Block for one event, then apply the whole pending batch. Everything
- /// goes through `core.update` rather than `postEvent`: a pty chunk borrows
- /// its bytes for the call, and mixing queued with immediate delivery would
- /// reorder a keystroke against the output it caused.
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = of(ctx);
var batch: usize = 0;
if (timeout_ms == 0) {
- // A failed read is a DEAD event source — the reader is gone and no
- // event can ever arrive again, so nothing could set `quit` and the
- // outer `while (!core.quit)` would spin at full speed. End the
- // session, exactly as the pre-vtable `try loop.nextEvent()` did.
const first = s.loop.nextEvent() catch {
s.core.quit = true;
return s.reloadWatched();
@@ -1030,20 +879,10 @@ const Shell = struct {
batch = 1;
if (s.apply(first)) return s.reloadWatched();
} else {
- // Animating: the frame clock IS the wait, and the `.tick` that
- // spends it is ours to post — `pump` cannot, because only this
- // host knows when a real frame interval has passed. Anything that
- // queues during the short sleep is drained below, in this pass.
std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {};
_ = s.apply(.tick);
batch = 1;
}
- // Apply every queued INPUT event, then render ONCE — the gui shell
- // drains SDL's queue the same way. Without this a wheel flick is fifty
- // full render+repaint (and re-highlight) cycles instead of one. A
- // paste in flight keeps draining WITHOUT rendering: a hundred thousand
- // pasted characters are one edit. That cannot spin — the drain still
- // ends the moment the queue runs dry.
while (s.in_paste or batch < 64) {
const ev = (s.loop.tryEvent() catch null) orelse break;
batch += 1;
@@ -1052,10 +891,6 @@ const Shell = struct {
s.reloadWatched();
}
- /// Apply one vaxis event. Returns true when the batch must end here: the
- /// session is over, a pty chunk wants its own frame so progress paints as
- /// it arrives, or a native PDF page crossing needs geometry this render
- /// has not produced yet.
fn apply(s: *Shell, event: @TypeOf(Command.value)) bool {
const core = s.core;
const tz_event = tracy.zone(@src(), "event");
@@ -1077,7 +912,8 @@ const Shell = struct {
core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } });
},
.pty_read => |pr| {
- core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
+ if (s.gens[pr.id] == pr.gen)
+ core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
s.gpa.free(pr.bytes);
return true;
},
@@ -1115,40 +951,20 @@ const Shell = struct {
},
.paste_end => {
s.in_paste = false;
- // ONE event for the whole paste — the core borrows the
- // bytes for the call, exactly like the OSC 52 arm above.
const pasted = s.paste_buf.written();
if (pasted.len > 0) core.update(.{ .paste = pasted });
s.paste_buf.clearRetainingCapacity();
},
- .command => |line| {
- core.update(.{ .command = line });
- s.gpa.free(line);
- },
- // Coalesced on purpose: a burst of writes (a formatter, a build, a
- // `git checkout`) collapses into ONE pass below, so it cannot
- // queue a reload — or an undo entry — per write.
.files_changed => s.check_files = true,
- // A wake and nothing more. The requests behind it are drained in
- // pollFrame, where the file-watch reload also happens: both want to
- // run once per frame with the whole batch already in, not once per
- // event. So this arm has nothing to do — which is the point, since
- // its only job was ending the blocking wait above.
.fs_ready => {},
.lsp_done => |d| {
core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } });
- s.lsp_gpa.free(d.rows);
- // the worker is finished; join it so its future does not
- // leak (same contract as pty_eof above)
- if (s.lsp_task) |*t| {
- t.await(s.io) catch {};
+ if (d.rows) |rows| s.lsp_gpa.free(rows);
+ if (s.lsp_task) |*t| if (t.id == d.id) {
+ t.future.await(s.io) catch {};
s.lsp_task = null;
- }
+ };
},
- // Server state on the transient message row — the same row, the
- // same `message.stamp` clock, and the same shell-side ownership a
- // completed save uses. The ACTIVE pane, because the state of a
- // server is session news, not a fact about the pane that asked.
.lsp_status => |text| {
var mbuf: [256]u8 = undefined;
core.setStatus(core.active, message.stamp(&mbuf, "lsp", text));
@@ -1176,58 +992,29 @@ const Shell = struct {
s.loop.postEvent(.files_changed) catch {};
}
- // ---- the frame ---------------------------------------------------------
-
fn pollFrame(ctx: ?*anyopaque) void {
const s = of(ctx);
- // acme's filesystem, answered here for the same reason the file-watch
- // reload is (see reloadWatched): one batch per frame, not one frame per
- // request. First in the pass, so an edit a script just made through
- // `body` is in the surface this frame composes rather than the next.
- if (s.fs) |f| if (fs_service.drain(f.transport(), s.core).pending) {
- // The batch hit its cap with requests still pending, and no ack has
- // gone to the poll thread — so nothing else will wake us. Re-arm
- // the loop ourselves. tryPostEvent, not postEvent: this runs on the
- // only thread that drains the queue, so blocking on a full one
- // would deadlock, and a full queue already holds a wake.
+ if (s.fs) |f| if (f.tick(s.core).pending) {
_ = s.loop.tryPostEvent(.fs_ready) catch {};
};
- // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the
- // pane's tty still belongs to the prompt we forked is NOT polled here —
- // it is a walk through /proc and nothing draws it, so the core pulls it
- // through tty_taken instead, at the Exec that cares.
for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| {
- var lbuf: [1024]u8 = undefined;
- if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd);
+ var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
+ if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd);
};
- // The handshake landed (vaxis's reader flips queries_done on DA1, the
- // last probe answered): put the terminal into the modes the caps now
- // claim, before anything is drawn under them. Polled here rather than
- // done where the replies arrive because that is the reader thread, and
- // this is the only thread allowed to touch the tty writer. The repaint
- // matters as much as the enable: earlier frames were drawn under the
- // pre-handshake caps, and vaxis's shadow grid has to be re-established
- // under the new ones or it keeps skipping cells it thinks are current.
if (s.caps_pending and s.vx.queries_done.load(.unordered)) {
s.caps_pending = false;
s.vx.enableDetectedFeatures(s.tty.writer()) catch {};
- // The core was constructed before the asynchronous handshake.
- // Advertise native pixels only now, after every reply preceding
- // DA1 has updated the capability set.
s.core.native_images = s.vx.caps.kitty_graphics;
s.vx.queueRefresh();
}
}
- /// The canonical surface -> vaxis, cell for cell, with no interpretation.
fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void {
const s = of(ctx);
const vx = s.vx;
s.tracks = canonical.panelTracks();
_ = s.frame.reset(.retain_capacity);
- // compose only READS the canonical surface; the mutable pointer is so
- // it can hand it straight back when no panel is mid-transition.
const surface = panel_compositor.compose(
s.frame.allocator(),
@constCast(canonical),
@@ -1238,16 +1025,8 @@ const Shell = struct {
const win = vx.window();
paintCells(win, surface.cells, surface.cols, surface.rows);
tz_cells.end();
- // Pixel attachments (kitty graphics): transmit once per pixel
- // generation, then re-place every frame (placements aren't
- // persistent). The map is keyed by pane lifetime + PDF page + pixel
- // revision, so any number of short visible pages can coexist without
- // aliasing a fixed terminal cache slot.
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
- // Keep the placement in Surface so the terminal-side cache stays
- // live, but do not pin native pixels over a panel whose cells are
- // currently moving through the TTY grid.
if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue;
if (comptime pardes.pdf_enabled) {
if (!kittyImageRepresentable(place)) continue;
@@ -1288,9 +1067,6 @@ const Shell = struct {
}
}
}
- // Toggling PETSCII or closing a pane removes its attachment from the
- // Surface. Release the terminal-side image then, not merely when that
- // numeric pane slot happens to be reused.
while (true) {
var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined;
var stale_len: usize = 0;
@@ -1317,33 +1093,18 @@ const Shell = struct {
tracy.frameMark();
}
- // ---- pseudo-terminals ---------------------------------------------------
-
fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
const s = of(ctx);
- // the core reuses pane ids and there is no close effect: a deleted
- // pane's shell lives in its slot until a respawn lands here — reap
- // it (cancel joins the reader; its late eof is ignored by gen)
if (s.ptys[pane]) |*old| {
old.reader.cancel(s.io) catch {};
_ = libc.close(old.file.handle);
s.ptys[pane] = null;
}
s.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);
- }
- const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
+ const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err);
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
- // Exec asks about the shell that is there now.
- var lbuf: [1024]u8 = undefined;
- if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
+ var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined;
+ if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd);
if (s.threads_ok) {
if (s.ptys[pane]) |*pt| {
pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader;
@@ -1364,47 +1125,27 @@ const Shell = struct {
}
}
- /// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal,
- /// which is the same silence `ptyWrite` above gives it.
fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void {
const s = of(ctx);
- if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig);
+ if (s.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig);
}
- /// Is a pane's tty still the prompt we forked? Lazy by construction — it
- /// runs only where the core is about to type a command line, so the /proc
- /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a
- /// document, a slot whose shell already died) is not a terminal a program
- /// can be holding.
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
const s = of(ctx);
const pt = s.ptys[pane] orelse return false;
- return look.ttyTaken(pt.pid, pt.file.handle);
+ return host_io.ttyTaken(pt.pid, pt.file.handle);
}
- // ---- the filesystem -----------------------------------------------------
-
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
- // SAY WHY, and tell the core it did not happen. A save that cannot be
- // done is the one failure this program must never swallow: `saveFailed`
- // puts the reason on the pane's message row and leaves the pane dirty,
- // so the ` *` stays and `Del` cannot quietly take the edits.
- host_io.writeFileBytes(path, bytes) catch |err|
+ filesystem.write(s.core, path, bytes) catch |err|
return s.core.saveFailed(pane, "save", err);
- // 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
- // silence a real change to the file the pane has open.
if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
.text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
.pdf => {},
};
};
- // ...and say so on the pane's message row. AFTER the write, not beside
- // it: every early return above is a save that did not happen and must
- // not be reported as one.
var mbuf: [256]u8 = undefined;
s.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
}
@@ -1413,13 +1154,13 @@ const Shell = struct {
const s = of(ctx);
var pbuf: [1024:0]u8 = undefined;
const path = pardes.dump.outPath(&pbuf) orelse return;
- host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err);
+ filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err);
s.core.setLastDump(path);
}
- fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void {
+ fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void {
const s = of(ctx);
- if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, &s.watches, pane, on))
+ if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, &s.watches, pane, on, mode))
s.loop.postEvent(.files_changed) catch {};
}
@@ -1429,21 +1170,10 @@ const Shell = struct {
s.loop.postEvent(.files_changed) catch {};
}
- /// The core's answer to one filesystem request, handed straight back to the
- /// transport that is holding it. `bytes` was resolved by `pardes.fsPayload`
- /// inside `perform` and is borrowed only for this call — a body read is a
- /// window onto the pane's live text, so there is nothing to copy and
- /// nothing to free. `.again` needs no special case here: `Fs.reply` reads
- /// the status and re-parks the request itself.
- fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void {
- const s = of(ctx);
- if (s.fs) |f| f.reply(reply, bytes);
- }
-
fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
const s = of(ctx);
const config_dir = s.core.opts.config_dir orelse return;
- const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
+ const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| {
s.core.reportError(pane, "dump themes", err);
return;
};
@@ -1452,16 +1182,6 @@ const Shell = struct {
s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
}
- // ---- 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.
-
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
const s = of(ctx);
copyToClipboard(s.vx, s.tty, s.gpa, text);
@@ -1476,96 +1196,171 @@ const Shell = struct {
look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly
}
- // ---- work that must leave the loop --------------------------------------
-
- fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void {
+ fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void {
const s = of(ctx);
- if (!s.threads_ok) return; // pre-loop drain: nothing to answer to yet
- const job = lsp_host.snapshot(s.lsp_gpa, s.core, req) orelse return;
- // ponytail: ONE query in flight, so one future slot. Replacing it
- // cancels-then-joins the previous worker, which for a backend that
- // ignores cancellation means waiting out a query the user already
- // abandoned. Queries are milliseconds; make this a real pool the day a
- // backend takes long enough to notice.
+ if (!s.threads_ok) {
+ s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } });
+ return s.core.reportError(req.pane, "lsp", error.WorkersUnavailable);
+ }
+ const job = host_io.Lsp.snapshot(s.lsp_gpa, s.core, req) catch |err| {
+ s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } });
+ return s.core.reportError(req.pane, "lsp", err);
+ };
if (s.lsp_task) |*old| {
- old.cancel(s.io) catch {};
+ old.future.cancel(s.io) catch {};
s.lsp_task = null;
}
- s.lsp_task = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch {
+ const future = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch |err| {
job.free(s.lsp_gpa);
- return;
+ s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } });
+ return s.core.reportError(req.pane, "lsp", err);
};
+ s.lsp_task = .{ .id = req.id, .future = future };
}
fn pipe(ctx: ?*anyopaque, id: u32) void {
const s = of(ctx);
- if (!s.threads_ok) return;
+ if (!s.threads_ok) {
+ s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
+ return s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable);
+ }
if (s.pipe_tasks.full()) {
s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
return;
}
const view = s.core.pipeRequest(id) orelse return;
- const job = selection_pipe.Job.copy(s.gpa, view) catch return;
- const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch {
+ const job = selection_pipe.Job.copy(s.gpa, view) catch |err| {
+ s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
+ return s.core.reportError(s.core.active, "pipe", err);
+ };
+ const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch |err| {
job.deinit(s.gpa);
- return;
+ s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } });
+ return s.core.reportError(s.core.active, "pipe", err);
};
- // `full()` was checked above, so this cannot fail; assert rather than
- // discard, because a silently dropped task is a future nobody joins.
std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future }));
}
};
-/// 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.
+test "TTY queued results reject old PTY generations and LSP request IDs" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer core.deinit();
+ var shell: Shell = .{
+ .io = std.testing.io,
+ .gpa = gpa,
+ .lsp_gpa = gpa,
+ .core = core,
+ .prompt_rcs = undefined,
+ .loop = undefined,
+ .vx = undefined,
+ .tty = undefined,
+ .kitty = undefined,
+ .frame = undefined,
+ .paste_buf = undefined,
+ .inotify_fd = -1,
+ };
+ shell.gens[0] = 2;
+ _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 1, .bytes = try gpa.dupe(u8, "old session\n") } });
+ _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 2, .bytes = try gpa.dupe(u8, "current session\n") } });
+ const text = try pardes.panes.Terminal.screenTextAlloc(core.panes[0].?, gpa);
+ defer gpa.free(text);
+ try std.testing.expect(std.mem.indexOf(u8, text, "old session") == null);
+ try std.testing.expect(std.mem.indexOf(u8, text, "current session") != null);
+
+ core.lspRequest(0, .status, "");
+ const old_id = core.lsp_wait.?.id;
+ core.lspRequest(0, .status, "");
+ const current_id = core.lsp_wait.?.id;
+ shell.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } };
+ _ = shell.apply(.{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } });
+ try std.testing.expect(shell.lsp_task != null);
+ try std.testing.expectEqual(current_id, shell.lsp_task.?.id);
+ try std.testing.expectEqual(current_id, core.lsp_wait.?.id);
+ _ = shell.apply(.{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } });
+ try std.testing.expect(shell.lsp_task == null);
+ try std.testing.expect(core.lsp_wait == null);
+}
+
+test "TTY worker setup failures finish matching LSP and pipe requests" {
+ const gpa = std.testing.allocator;
+ var failing_vtable = std.testing.io.vtable.*;
+ failing_vtable.concurrent = std.Io.failingConcurrent;
+ const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable };
+ for (0..2) |failure| {
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ const pane = try core.setTestFile("abc");
+ var failing = std.testing.FailingAllocator.init(gpa, .{
+ .fail_index = if (failure == 0) 0 else std.math.maxInt(usize),
+ });
+ var shell: Shell = .{
+ .io = failing_io,
+ .gpa = failing.allocator(),
+ .lsp_gpa = failing.allocator(),
+ .core = core,
+ .prompt_rcs = undefined,
+ .loop = undefined,
+ .vx = undefined,
+ .tty = undefined,
+ .kitty = undefined,
+ .frame = undefined,
+ .paste_buf = undefined,
+ .inotify_fd = -1,
+ .threads_ok = true,
+ };
+ core.lspRequest(core.active, .status, "");
+ const req: host_io.Lsp.Request = .{
+ .id = core.lsp_wait.?.id,
+ .kind = .status,
+ .pane = @intCast(core.active),
+ .offset = 0,
+ .arg = "",
+ };
+ Shell.lsp(&shell, req);
+ try std.testing.expect(core.lsp_wait == null);
+ try std.testing.expectEqualStrings("abc", pane.file.?.content);
+
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+ core.update(.{ .key = .{ .cp = '|' } });
+ core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } });
+ core.update(.{ .key = .{ .cp = pardes.Key.enter } });
+ const pipe_id = core.pipe_wait.?.id;
+ Shell.pipe(&shell, pipe_id);
+ try std.testing.expect(core.pipe_wait == null);
+ try std.testing.expectEqualStrings("abc", pane.file.?.content);
+ try std.testing.expect(shell.lsp_task == null);
+ try std.testing.expectEqual(@as(usize, 0), shell.pipe_tasks.len);
+ }
+}
+
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. The
-/// snapshot and the query body are `lsp_host`'s; the only part that is this
-/// shell's is the vaxis event the rows travel home on.
-fn lspWorker(allocator: std.mem.Allocator, job: *lsp_host.Job, loop: *Loop) anyerror!void {
+fn lspWorker(allocator: std.mem.Allocator, job: *host_io.Lsp.Job, loop: *Loop) anyerror!void {
var sink: LspRowSink = .{ .allocator = allocator, .loop = loop };
- lsp_host.work(allocator, job, &sink, LspRowSink.take);
+ host_io.Lsp.work(allocator, job, &sink, LspRowSink.take);
}
const LspRowSink = struct {
allocator: std.mem.Allocator,
loop: *Loop,
- fn take(ctx: ?*anyopaque, id: u32, rows: []u8) void {
+ fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void {
const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return));
- s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch s.allocator.free(rows);
+ s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch {
+ if (rows) |owned| s.allocator.free(owned);
+ };
}
};
-/// The registered `lsp.setStatusSink` target, called from the protocol
-/// client's READER threads. Only thread-safe, NON-BLOCKING things happen
-/// here: a dupe with the concurrent lsp allocator and a TRY-post onto the
-/// loop's queue. Never the blocking post — the sink lock is held around this
-/// call, and a full queue plus a teardown spinning on that lock would be a
-/// deadlock; server state is periodic news, so a dropped line is repriced
-/// by the next one.
fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void {
const s: *Shell = @ptrCast(@alignCast(ctx orelse return));
const copy = s.lsp_gpa.dupe(u8, text) catch return;
@@ -1584,22 +1379,7 @@ fn pipeWorker(
loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa);
}
-/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the
-/// events: the loop re-reads every watched pane anyway, so the only thing an
-/// event carries that we need is THAT something happened, and parsing would
-/// mean sharing the watch table with the thread that mutates it. Same shape as
-/// readPty — block off the loop, hand the loop an event, keep the core a state
-/// machine that never blocks. Going through std.Io.File rather than a raw
-/// read(2) is what lets the teardown `cancel` interrupt it.
-///
-/// ponytail: churn in a watched directory that never touches the watched file
-/// still costs a wake and a re-read per event. The ceiling is one directory
-/// per open file pane; filter by basename here if it ever shows up in a
-/// profile.
fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void {
- // A kqueue cannot be read, so there is no std.Io.File to wrap and no
- // `cancel` to interrupt: this arm parks in kevent(2) and the teardown's
- // `file_watch.stop` is what releases it. See file_watch.wait.
if (comptime builtin.os.tag != .linux) {
while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break;
return;
@@ -1616,32 +1396,6 @@ fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void {
}
}
-/// Block on the nested-instance socket and hand the loop each command line a
-/// pardes started inside this one sends. Same shape as winchWatch: a plain
-/// detached thread around a call that never returns, posting into the vaxis
-/// loop from ordinary thread context.
-///
-/// Nothing here is woken by teardown — close(2) does NOT release a thread
-/// parked in accept4 on linux — so this dies with the process, exactly as
-/// winchWatch dies inside sigwait. The window that leaves is one connection
-/// accepted between the last drain and process exit posting into a queue whose
-/// owner has returned; same shape and same bound as every other detached
-/// worker here, and a self-pipe to close it would be more machinery than the
-/// window is worth.
-fn lookServer(gpa: std.mem.Allocator, fd: c_int, loop: *Loop) void {
- var buf: [nested.max_line]u8 = undefined;
- while (nested.acceptLine(fd, &buf)) |line| {
- const owned = gpa.dupe(u8, line) catch continue;
- loop.postEvent(.{ .command = owned }) catch {
- gpa.free(owned);
- break;
- };
- }
-}
-
-/// Consume SIGWINCH synchronously (it is blocked in every thread) and post
-/// the new size as a winsize event from normal thread context — the one place
-/// vaxis's Io-backed queue is safe to touch on a resize.
fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
var set = posix.sigemptyset();
posix.sigaddset(&set, posix.SIG.WINCH);
@@ -1654,11 +1408,6 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
}
}
-/// The /dev/fuse poller's wake, and deliberately nothing else — the thread that
-/// calls this has no business in the core, so all it does is end the blocking
-/// `nextEvent`. tryPostEvent rather than postEvent for the same reason readPty's
-/// final post uses it: this can fire after the loop has already been left, and a
-/// blocking push into a full queue nobody is draining would never return.
fn wakeFs(ctx: ?*anyopaque) void {
const loop: *Loop = @ptrCast(@alignCast(ctx.?));
_ = loop.tryPostEvent(.fs_ready) catch {};
@@ -1673,18 +1422,14 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen:
const n = reader.interface.readVec(&vec) catch break;
if (n == 0) break;
const bytes = try gpa.dupe(u8, buf[0..n]);
- loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch {
+ loop.postEvent(.{ .pty_read = .{ .id = id, .gen = gen, .bytes = bytes } }) catch {
gpa.free(bytes);
break;
};
}
- // non-blocking: a teardown cancel only unblocks one wait, so a blocking
- // post into a full queue here could hang the exit
_ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {};
}
-/// The effective codepoint the way vaxis Key.matches sees it: a single-char
-/// text wins (shift resolved by the terminal), else the shifted codepoint.
fn effCp(key: vaxis.Key) u21 {
if (key.text) |t| {
const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
@@ -1696,8 +1441,6 @@ fn effCp(key: vaxis.Key) u21 {
return key.shifted_codepoint orelse key.codepoint;
}
-/// vaxis functional-key codepoints -> core Key constants (ASCII ones already
-/// coincide: enter/tab/escape/backspace pass through).
fn mapKey(cp: u21) u21 {
return switch (cp) {
vaxis.Key.up => pardes.Key.up,
@@ -1713,37 +1456,17 @@ fn mapKey(cp: u21) u21 {
};
}
-/// 4 MiB ceiling, past which the tail is dropped rather than grown into. A
-/// paste that large is a mis-click on a file, not an edit, and the core would
-/// have to hold the whole of it as one undo entry. File scope rather than a
-/// `Shell` decl because the `--attach` frontend accumulates the same bursts
-/// against the same ceiling, and wire.zig cites this name as THE cap.
const max_paste_bytes: usize = 4 << 20;
-/// One key press between the bracketed-paste markers, as the bytes it means.
-/// A key in there is DATA, never a command, and the two callers — the
-/// in-process host and the `--attach` frontend — must agree exactly, because
-/// what they produce is compared against what a terminal's own paste would
-/// have delivered.
fn pasteBytes(key: vaxis.Key) []const u8 {
const text = key.text orelse "";
if (text.len > 0) return text;
const cp = mapKey(effCp(key));
if (cp == pardes.Key.tab) return "\t";
- // vaxis gives control bytes no text at all: a line break inside a paste
- // reaches the ground parser as a bare CR (-> Key.enter) or, from a
- // terminal that does not translate them, a bare LF — which that parser
- // reports as ctrl+j. Nothing in here is a real keypress, so both of them
- // are just a newline.
if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n";
- // arrows, F-keys, a stray escape: noise a paste has no business carrying,
- // dropped rather than smuggled in.
return "";
}
-/// ...and one ordinary key press as the core's event. Shared for the reason
-/// above: a keystroke must mean the same thing whether the core is in this
-/// process or on the other end of a socket.
fn keyEvent(key: vaxis.Key) pardes.Event {
return .{ .key = .{
.cp = mapKey(effCp(key)),
@@ -1754,9 +1477,6 @@ fn keyEvent(key: vaxis.Key) pardes.Event {
} };
}
-/// ...and one mouse report. Null for a button this vocabulary has no name for
-/// (vaxis reports more of them than the core has), which is a report to drop
-/// rather than a press to invent.
fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
const button: pardes.Mouse.Button = switch (m.button) {
.left => .left,
@@ -1783,14 +1503,6 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
} };
}
-/// THE cell walk: canonical cells -> vaxis, cell for cell, with no
-/// interpretation. One walk and two callers, because a `frame` off the
-/// detached wire IS a `Surface`'s cells — wire.zig carries the grid and
-/// deliberately does not carry the two halves `Shell.present` adds around this
-/// (the kitty attachments, which have no encoding, and the panel transition,
-/// which the session composes before it sends). A second walk here would be
-/// two renderers for one canonical interface, and the interface is the thing
-/// this editor is.
fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
win.clear();
var y: u16 = 0;
@@ -1809,7 +1521,6 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1
fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void {
win.showCursor(x, y);
- // insert = beam, everything else = the terminal's default shape
win.setCursorShape(if (bar) .beam else .default);
}
@@ -1843,60 +1554,19 @@ fn vaxisColor(c: pardes.Color) vaxis.Color {
};
}
-// ---------------------------------------------------------------------------
-// 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.
-/// A VALUE rather than a message printed where it is discovered: at that point
-/// the alt screen is still up and everything written to it is erased by the
-/// restore a moment later. `run` registers `report` BEFORE it opens the
-/// terminal, so LIFO runs it last — on a cooked main screen, which is the one
-/// screen a person would go looking at.
const AttachEnd = union(enum) {
- /// not an `--attach` run at all, or the session said `quit`: exit 0
none,
- /// `--attach=<name>` and nothing is listening under it
no_session: []const u8,
- /// bare `--attach` with no session to mean...
nothing_detached,
- /// ...or more than one, which is a choice and not ours to make
ambiguous: usize,
- /// the session hung up on the connect and said why
refused: wire.Refusal,
- /// the link died, or the stream stopped making sense
lost: anyerror,
- /// the connect landed and the session hung up before 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,
@@ -1934,17 +1604,8 @@ const AttachEnd = union(enum) {
if (!ok) std.process.exit(1);
}
- /// 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",
@@ -1959,39 +1620,11 @@ const AttachEnd = union(enum) {
.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",
};
}
};
-/// `--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, `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 {
gpa: std.mem.Allocator,
client: *detached_client.Client,
@@ -1999,97 +1632,41 @@ const Attach = struct {
vx: *vaxis.Vaxis,
tty: *vaxis.Tty,
paste_buf: *std.Io.Writer.Allocating,
- /// 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,
in_paste: bool = false,
- /// A frame landed. Painted once at the end of the round rather than where
- /// it arrives: several can be decoded out of one poll and only the last of
- /// them is on the screen.
dirty: bool = false,
- /// One event onto the wire. Non-null ends this frontend.
fn send(a: *Attach, ev: pardes.Event) ?AttachEnd {
a.client.send(.{ .event = ev }) catch |err| switch (err) {
- // A message this protocol cannot carry, which is not a link that
- // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB).
- // 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. `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
- // the session promises a fresh attach is the next thing to arrive.
.welcome => {},
.refuse => |why| return .{ .refused = why },
- // Applied too — `grid` and `cursor` are current by the time this
- // returns, so all that is left is to say the screen moved.
.frame => a.dirty = true,
.quit => return .none,
- // ...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` 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;
}
- /// One vaxis event, translated onto the wire. Non-null ends the loop.
fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd {
switch (event) {
- // Nothing posts these here, and each absence has a reason. `tick`
- // is `Shell.waitInput`'s animation clock, and an animation runs
- // where the core is — the session sleeps on its own frame interval
- // (server.zig `nap`) and the frames simply arrive. `fs_ready`
- // belongs to `--fs`, which lives with the core. `lsp_done` and
- // `pipe_done` answer work the core dispatches, and it dispatches it
- // there; `lsp_status` narrates servers whose sink the local loop
- // UNSET in its teardown before this loop started, and the queue
- // was drained after that, so none is in flight. `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, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
.focus_in => {},
.focus_out => return a.send(.pointer_leave),
.winsize => |ws| {
a.vx.resize(a.gpa, a.tty.writer(), ws) catch {};
- // Repaint from the frame already in hand: vaxis has just thrown
- // its shadow grid away, and the SESSION grid may not move at
- // all — it is the smallest common one and another frontend may
- // be the small one (client.zig GEOMETRY).
a.dirty = true;
a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err };
},
@@ -2110,39 +1687,17 @@ const Attach = struct {
.paste_end => {
a.in_paste = false;
defer a.paste_buf.clearRetainingCapacity();
- // ONE message for the whole paste, exactly as the in-process
- // host makes it one `update`.
const pasted = a.paste_buf.written();
if (pasted.len > 0) return a.send(.{ .paste = pasted });
},
- // 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 });
- },
}
return null;
}
- /// The capability handshake, resolved on the loop exactly as
- /// `Shell.pollFrame` resolves it and for its reason: the replies land on
- /// vaxis's reader thread, and this is the only thread allowed to write to
- /// the tty. No `native_images` here — this wire carries no attachments.
fn enableCaps(a: *Attach) void {
if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return;
a.caps_pending = false;
a.vx.enableDetectedFeatures(a.tty.writer()) catch {};
- // Earlier frames were drawn under the pre-handshake caps, and vaxis's
- // shadow grid has to be re-established under the new ones or it keeps
- // skipping cells it thinks are current.
a.vx.queueRefresh();
a.dirty = true;
}
@@ -2150,72 +1705,33 @@ const Attach = struct {
fn paint(a: *Attach) void {
a.dirty = false;
const win = a.vx.window();
- // The session grid can be smaller than this window; `paintCells` clears
- // first, so the surplus is the terminal's own default cell rather than
- // whatever was there a frame ago.
paintCells(win, a.client.grid.items, a.client.cols, a.client.rows);
if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
a.vx.render(a.tty.writer()) catch {};
}
};
-/// 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;
@@ -2225,48 +1741,23 @@ fn attachLoop(a: *Attach) 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;
- // The hello carries this window, so the size has to be real before it goes
- // out: a session told 80x24 by a 200x50 terminal reflows every pane twice,
- // once now and once on the first SIGWINCH. `vx.resize` here rather than
- // waiting for the loop's first event for the same reason — the first frame
- // may arrive before any terminal event does, and it has to have somewhere
- // to be painted.
const ws = tty.getWinsize() catch |err| return .{ .lost = err };
if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize };
vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err };
- // 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.
defer client.detach();
var loop: Loop = .init(io, tty, vx);
+ var input_cache: vaxis.GraphemeCache = .{};
var paste_buf: std.Io.Writer.Allocating = .init(gpa);
defer paste_buf.deinit();
@@ -2277,26 +1768,13 @@ fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx:
.vx = vx,
.tty = tty,
.paste_buf = &paste_buf,
- // 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,
};
- // 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();
- // 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.
+ startInput(&loop, &input_cache) catch |err| return .{ .lost = err };
+ defer stopInput(&loop);
(std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach();
- // 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 {};
return attachLoop(&a);