diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/tty | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/tty')
| -rw-r--r-- | src/tty/panel_compositor.zig | 28 | ||||
| -rw-r--r-- | src/tty/tty.zig | 1362 |
2 files changed, 434 insertions, 956 deletions
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 2ca0263e..9363841a 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -7,10 +7,10 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); -const panel_animation = @import("../panel_animation.zig"); +const layout = @import("../layout.zig"); -const Box = panel_animation.Box; -const Track = panel_animation.Track; +const Box = layout.Box; +const Track = layout.Track; /// Kitty placements cannot be resampled through the character-grid transform. /// Keep their transmitted pixels cached, but omit the placement while its pane @@ -77,7 +77,7 @@ pub fn compose( // Stable layout motion first, newly opening panels above it, and closing // tombstones last. A closing pane no longer owns input or canonical cells, // but its frozen old content remains the top visual until it slides out. - for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]layout.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!drawable(source, track) or track.phase != phase) continue; switch (track.effect) { @@ -139,7 +139,7 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v var x = area.x0; while (x < area.x1) : (x += 1) { if (!source.panelCellChanged(x, y)) continue; - if (panel_animation.dissolveRevealed( + if (layout.dissolveRevealed( track.serial, x - area.x0, y - area.y0, @@ -339,7 +339,7 @@ test "TTY content effects never touch cells outside the published diff" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]panel_animation.Transition{ .ascii, .dissolve }) |effect| { + for ([_]layout.Transition{ .ascii, .dissolve }) |effect| { const track: Track = .{ .serial = 17, .effect = effect, @@ -366,7 +366,7 @@ test "content transition without a diff snaps to canonical surface" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]panel_animation.Transition{ .dissolve, .ascii, .vertical }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii, .vertical }) |effect| { const track: Track = .{ .effect = effect, .phase = .opening, @@ -391,7 +391,7 @@ test "exact transition endpoint preserves the canonical cursor" { }; const track: Track = .{ .effect = .ascii, - .frame = panel_animation.Transition.ascii.frames() - 1, + .frame = layout.Transition.ascii.frames() - 1, .from = .{ .w = 1, .h = 1 }, .to = .{ .w = 1, .h = 1 }, }; @@ -431,7 +431,7 @@ test "vertical opening rises through a fixed old-grid clip" { .serial = 5, .phase = .opening, .effect = .vertical, - .from = panel_animation.openingBox(.vertical, target, 3), + .from = layout.openingBox(.vertical, target, 3), .to = target, }; @@ -485,7 +485,7 @@ test "vertical closing drops frozen content over canonical cells" { .phase = .closing, .effect = .vertical, .from = old_box, - .to = panel_animation.closingBox(.vertical, old_box), + .to = layout.closingBox(.vertical, old_box), }; const first = try compose(arena.allocator(), &surface, &.{track}, null); @@ -530,13 +530,13 @@ test "closing content paints after opening content regardless of track order" { .phase = .closing, .effect = .vertical, .from = box, - .to = panel_animation.closingBox(.vertical, box), + .to = layout.closingBox(.vertical, box), }; const opening: Track = .{ .serial = 2, .phase = .opening, .effect = .ascii, - .frame = panel_animation.Transition.ascii.frames() - 1, + .frame = layout.Transition.ascii.frames() - 1, .from = box, .to = box, }; @@ -691,8 +691,8 @@ test "active panel transition hides only its own native attachment" { const tracks = [_]Track{ .{ .serial = 41, .effect = .slide, .frame = 0 }, .{ .serial = 42, .effect = .ascii, .frame = 0 }, - .{ .serial = 43, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() - 1 }, - .{ .serial = 44, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() }, + .{ .serial = 43, .effect = .zoom, .frame = layout.Transition.zoom.frames() - 1 }, + .{ .serial = 44, .effect = .zoom, .frame = layout.Transition.zoom.frames() }, .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 }, }; 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); |
