From 11f380f6d7222f2cad93c2cdf13701ea1f903d47 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 13:27:46 -0300 Subject: One core behind N frontends, the board's own runner moved in, and every board cap on one screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason. --- src/tty/tty.zig | 882 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 792 insertions(+), 90 deletions(-) (limited to 'src/tty/tty.zig') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index c5abf0fe..f8042d71 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -21,6 +21,13 @@ const fuse = @import("../fuse.zig"); const fs_service = @import("../fs_service.zig"); const panel_compositor = @import("panel_compositor.zig"); const host_api = @import("../host.zig"); +const config = @import("../config.zig"); +// The other half of `--detach`, and the reason this file has an `--attach` +// branch at all: the frontend side of a detached session is a terminal and a +// socket, and this file is already the one that owns a terminal. +const detached_client = @import("../detached/client.zig"); +const detached_server = @import("../detached/server.zig"); +const wire = @import("../detached/wire.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -445,10 +452,29 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee return null; } -pub fn run(init: std.process.Init, opts: pardes.Options) !void { +/// `attach` is `--attach[=]`: empty means "the session there is" (see +/// `sessionName`). It is a parameter rather than an `Options` field because it +/// says nothing to the core — this process does not have one when it is set. +pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void { const io = init.io; const gpa = init.gpa; + // `--attach` only, and registered HERE — before the terminal is opened — + // for the LIFO: it must run after every deferred restore below. Why a + // frontend stopped is discovered deep inside the loop while the alt screen + // is still up, and anything written there is erased by the switch back to + // the main screen, which is the one screen a user would look at. + var attach_end: AttachEnd = .none; + defer attach_end.report(io); + + // ...and resolved before the terminal too, for the same reason turned the + // other way: "no session called work" is a launch that never started, and + // flashing the alt screen up and straight back down to say so is worse + // than never entering it. + var name_buf: [detached_server.path_max]u8 = undefined; + var attach_name: []const u8 = &.{}; + if (attach) |requested| attach_name = sessionName(&name_buf, requested, &attach_end) orelse return; + const allocs = pardes.allocators.init(gpa); defer pardes.allocators.deinit(); var options = opts; @@ -495,6 +521,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // tty is still open — sends the disable off that flag. try vx.setBracketedPaste(tty.writer(), true); + // `--attach`: this process has a terminal and NO core. Everything above is + // the terminal, which an attached frontend needs exactly as much as a whole + // session does; everything below is the core, which lives in the detached + // process. The branch is here so both leave by the same door — an attach + // has to restore cooked mode, the main screen and the mouse the way an + // ordinary exit does, and sharing the deferred teardown is the only way to + // guarantee that instead of asserting it. + if (attach != null) { + attach_end = attachSession(init, opts, attach_name, &tty, &vx); + return; + } + pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -796,11 +834,6 @@ const Shell = struct { /// the tracks the frame in flight was composed from tracks: []const pardes.panel_animation.Track = &.{}, - /// 4 MiB ceiling, past which the tail is dropped rather than grown into. A - /// paste that large is a mis-click on a file, not an edit, and the core - /// would have to hold the whole of it as one undo entry. - const max_paste_bytes: usize = 4 << 20; - fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } @@ -924,61 +957,13 @@ const Shell = struct { core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, .key_press => |key| if (s.in_paste) { - // Between the markers a key is DATA, never a command. Same - // two inputs as the dispatch below, so a pasted character - // is exactly the character the core would have been given. - const text = key.text orelse ""; - const cp = mapKey(effCp(key)); - const bytes: []const u8 = if (text.len > 0) - text - else if (cp == pardes.Key.tab) - "\t" - else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) - // vaxis gives control bytes no text at all: a line - // break inside a paste reaches the ground parser as a - // bare CR (-> Key.enter) or, from a terminal that does - // not translate them, a bare LF — which that parser - // reports as ctrl+j. Nothing in here is a real - // keypress, so both of them are just a newline. - "\n" - else - // arrows, F-keys, a stray escape: noise a paste has no - // business carrying, dropped rather than smuggled in. - ""; + const bytes = pasteBytes(key); const room = max_paste_bytes -| s.paste_buf.written().len; s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; - } else core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = key.text orelse "", - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - .shift = key.mods.shift, - } }), + } else core.update(keyEvent(key)), .mouse => |m| { const pdf_before = nativePdfWheelTarget(core, m); - const button: ?pardes.Mouse.Button = switch (m.button) { - .left => .left, - .middle => .middle, - .right => .right, - .wheel_up => .wheel_up, - .wheel_down => .wheel_down, - .wheel_left => .wheel_left, - .wheel_right => .wheel_right, - .none => .none, // button-less motion: hover tracking - else => null, - }; - if (button) |b| core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(m.col), - .row = @intCast(m.row), - .ctrl = m.mods.ctrl, - } }); + if (mouseEvent(m)) |ev| core.update(ev); if (pdf_before) |before| { if (core.panes[before.pane]) |pane| { if (pane.pdfPage()) |page| { @@ -1108,19 +1093,7 @@ const Shell = struct { ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); - win.clear(); - var y: u16 = 0; - while (y < surface.rows) : (y += 1) { - var x: u16 = 0; - while (x < surface.cols) : (x += 1) { - const cell = surface.at(x, y); - if (cell.default) continue; - win.writeCell(x, y, .{ - .char = .{ .grapheme = cell.grapheme() }, - .style = vaxisStyle(cell.style), - }); - } - } + paintCells(win, surface.cells, surface.cols, surface.rows); tz_cells.end(); // Pixel attachments (kitty graphics): transmit once per pixel // generation, then re-place every frame (placements aren't @@ -1189,11 +1162,7 @@ const Shell = struct { vx.freeImage(s.tty.writer(), removed.value.id); if (stale_len < stale.len) break; } - if (surface.cursor) |cur| { - win.showCursor(cur.x, cur.y); - // insert = beam, everything else = the terminal's default shape - win.setCursorShape(if (cur.bar) .beam else .default); - } + if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); vx.render(s.tty.writer()) catch {}; tz_render.end(); @@ -1267,14 +1236,7 @@ const Shell = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - var pathbuf: [4096:0]u8 = undefined; - if (path.len >= pathbuf.len) return; - @memcpy(pathbuf[0..path.len], path); - pathbuf[path.len] = 0; - const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return; - writeFd(fd, bytes); - _ = libc.close(fd); + if (!writeFileBytes(path, bytes)) return; // our own write is about to come back as a watch event: restamp from // the bytes we just put there so it reads as "no change". Only when // this IS the pane's watched file — a `Save ` must not @@ -1296,10 +1258,7 @@ const Shell = struct { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return; - writeFd(fd, bytes); - _ = libc.close(fd); + if (!writeFileBytes(path, bytes)) return; s.core.setLastDump(path); } @@ -1544,7 +1503,15 @@ fn wakeFs(ctx: ?*anyopaque) void { _ = loop.tryPostEvent(.fs_ready) catch {}; } -fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) struct { file: std.Io.File, pid: posix.pid_t } { +/// `core` is null in an `--attach` frontend, which forks the pane shells for a +/// core that is in another process entirely. The two things it is used for are +/// both messages BACK to that core — which pane serial the child's environment +/// should name, and which binary was actually executed — and neither has a +/// place on the detached wire (see wire.zig): a detached session's `--fs` +/// stays in the session, and `acknowledgeShell` has no `ClientTag`. So both +/// are skipped rather than faked, and the pane tag in a detached session +/// simply does not name its shell. +fn forkShell(core: ?*pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) struct { file: std.Io.File, pid: posix.pid_t } { var master: c_int = undefined; // resolved BEFORE the fork, into this frame, which the child inherits: // nothing between fork and exec may allocate, and a PATH search would @@ -1554,7 +1521,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro // second reason on top of that one: acme puts `winid` in the child, which // is safe there only because rfork(RFENVG) has just given it a private // environment group. See fs_service.exportPaneEnv. - fs_service.exportPaneEnv(fs, if (core.panes[pane]) |pn| pn.serial else 0); + fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { @@ -1568,7 +1535,7 @@ fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.Pro _ = execv(spawn.path, &spawn.argv); _exit(127); } - if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); + if (pid > 0) if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } @@ -1621,6 +1588,125 @@ fn mapKey(cp: u21) u21 { }; } +/// 4 MiB ceiling, past which the tail is dropped rather than grown into. A +/// paste that large is a mis-click on a file, not an edit, and the core would +/// have to hold the whole of it as one undo entry. File scope rather than a +/// `Shell` decl because the `--attach` frontend accumulates the same bursts +/// against the same ceiling, and wire.zig cites this name as THE cap. +const max_paste_bytes: usize = 4 << 20; + +/// One key press between the bracketed-paste markers, as the bytes it means. +/// A key in there is DATA, never a command, and the two callers — the +/// in-process host and the `--attach` frontend — must agree exactly, because +/// what they produce is compared against what a terminal's own paste would +/// have delivered. +fn pasteBytes(key: vaxis.Key) []const u8 { + const text = key.text orelse ""; + if (text.len > 0) return text; + const cp = mapKey(effCp(key)); + if (cp == pardes.Key.tab) return "\t"; + // vaxis gives control bytes no text at all: a line break inside a paste + // reaches the ground parser as a bare CR (-> Key.enter) or, from a + // terminal that does not translate them, a bare LF — which that parser + // reports as ctrl+j. Nothing in here is a real keypress, so both of them + // are just a newline. + if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n"; + // arrows, F-keys, a stray escape: noise a paste has no business carrying, + // dropped rather than smuggled in. + return ""; +} + +/// ...and one ordinary key press as the core's event. Shared for the reason +/// above: a keystroke must mean the same thing whether the core is in this +/// process or on the other end of a socket. +fn keyEvent(key: vaxis.Key) pardes.Event { + return .{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }; +} + +/// ...and one mouse report. Null for a button this vocabulary has no name for +/// (vaxis reports more of them than the core has), which is a report to drop +/// rather than a press to invent. +fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { + const button: pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, + .none => .none, // button-less motion: hover tracking + else => return null, + }; + return .{ .mouse = .{ + .button = button, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + .ctrl = m.mods.ctrl, + } }; +} + +/// THE cell walk: canonical cells -> vaxis, cell for cell, with no +/// interpretation. One walk and two callers, because a `frame` off the +/// detached wire IS a `Surface`'s cells — wire.zig carries the grid and +/// deliberately does not carry the two halves `Shell.present` adds around this +/// (the kitty attachments, which have no encoding, and the panel transition, +/// which the session composes before it sends). A second walk here would be +/// two renderers for one canonical interface, and the interface is the thing +/// this editor is. +fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { + win.clear(); + var y: u16 = 0; + while (y < rows) : (y += 1) { + var x: u16 = 0; + while (x < cols) : (x += 1) { + const cell = &cells[@as(usize, y) * cols + x]; + if (cell.default) continue; + win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); + } + } +} + +fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { + win.showCursor(x, y); + // insert = beam, everything else = the terminal's default shape + win.setCursorShape(if (bar) .beam else .default); +} + +/// Create-or-truncate `path` and put `bytes` there. The half of `write_file` +/// that is nothing but the filesystem, so that the frontend which has a disk +/// and no core and the host which has both write a file the same way. +/// Everything else in `Shell.writeFile` — the watch restamp, the "saved" +/// message — is the CORE's memory of the write and stays with whoever owns +/// one. False is a save that did not happen, which no caller may report as +/// one. +fn writeFileBytes(path: []const u8, bytes: []const u8) bool { + var pathbuf: [4096:0]u8 = undefined; + if (path.len >= pathbuf.len) return false; + @memcpy(pathbuf[0..path.len], path); + pathbuf[path.len] = 0; + const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return false; + writeFd(fd, bytes); + _ = libc.close(fd); + return true; +} + fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { return .{ .fg = vaxisColor(s.fg), @@ -1662,3 +1748,619 @@ fn writeFd(fd: c_int, data: []const u8) void { off += @intCast(n); } } + +// --------------------------------------------------------------------------- +// --attach: a terminal, a socket, and no core +// --------------------------------------------------------------------------- + +/// Why an `--attach` frontend stopped, and where its exit status comes from. +/// A VALUE rather than a message printed where it is discovered: at that point +/// the alt screen is still up and everything written to it is erased by the +/// restore a moment later. `run` registers `report` BEFORE it opens the +/// terminal, so LIFO runs it last — on a cooked main screen, which is the one +/// screen a person would go looking at. +const AttachEnd = union(enum) { + /// not an `--attach` run at all, or the session said `quit`: exit 0 + none, + /// `--attach=` and nothing is listening under it + no_session: []const u8, + /// bare `--attach` with no session to mean... + nothing_detached, + /// ...or more than one, which is a choice and not ours to make + ambiguous: usize, + /// the session hung up on the connect and said why + refused: wire.Refusal, + /// the link died, or the stream stopped making sense + lost: anyerror, + /// the connect landed and the session hung up before the hello was + /// answered: a refusal whose reason raced the close (see `attachSession`) + rejected, + + /// The nonzero exit lives HERE for the same reason the message does: every + /// teardown this process owes is a defer registered after this one, so by + /// the time this runs they have all run and there is nothing left to skip. + fn report(e: AttachEnd, io: std.Io) void { + var buf: [512]u8 = undefined; + const text: []const u8 = switch (e) { + .none => return, + .no_session => |name| std.fmt.bufPrint( + &buf, + "pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n", + .{ name, name }, + ) catch "pardes: no detached session under that name\n", + .nothing_detached => "pardes: no detached session is running (start one with `pardes --detach`)\n", + .ambiguous => |n| std.fmt.bufPrint( + &buf, + "pardes: {d} detached sessions are running; say which with --attach=\n", + .{n}, + ) catch "pardes: several detached sessions are running; say which with --attach=\n", + .refused => |why| switch (why) { + .version => "pardes: that session speaks a different wire version — it is another build of pardes\n", + .full => "pardes: that session already has every frontend slot taken\n", + .quitting => "pardes: that session is ending\n", + }, + .lost => |err| std.fmt.bufPrint(&buf, "pardes: detached session lost: {t}\n", .{err}) catch + "pardes: detached session lost\n", + .rejected => "pardes: that session hung up on the connect — every frontend slot is taken (32), or it is shutting down. `PARDES_LOG=1` on the session names which\n", + }; + std.Io.File.stderr().writeStreamingAll(io, text) catch {}; + std.process.exit(1); + } +}; + +/// The session `--attach` meant. `--attach=` is the name it says; bare +/// `--attach` is THE session, because bare `--detach` names itself by its own +/// pid and nobody can be expected to read a pid out of `$XDG_RUNTIME_DIR`. +/// With exactly one session listening that is the one meant; with none or +/// several this says which case it is instead of picking one. Null means "do +/// not open a terminal", with `end` already saying why. +/// +/// Both the directory and the filename convention come off ONE probe through +/// `server.sessionPath`, rather than being re-derived here, for the reason that +/// function exists at all: the side that binds and the side that looks must not +/// be able to disagree about where a session lives. +fn sessionName(buf: *[detached_server.path_max]u8, requested: []const u8, end: *AttachEnd) ?[]const u8 { + if (requested.len != 0) { + // A name is taken at its word — the connect in `attachSession` is the + // authority on whether anything is listening — except for the one case + // that is worth catching before a terminal is opened at all: a typo, + // where there is no socket file of that name whatsoever. Getting that + // wrong is the common failure, and the alternative is a full-screen + // alt-screen flash on the way to a one-line message. + var one_buf: [detached_server.path_max]u8 = undefined; + const one = detached_server.sessionPath(&one_buf, requested) orelse { + end.* = .{ .no_session = requested }; + return null; + }; + const F_OK: c_int = 0; + if (libc.access(one, F_OK) != 0) { + end.* = .{ .no_session = requested }; + return null; + } + return requested; + } + const probe_name = "0"; + var probe_buf: [detached_server.path_max]u8 = undefined; + const probe = detached_server.sessionPath(&probe_buf, probe_name) orelse { + end.* = .nothing_detached; + return null; + }; + const base = std.fs.path.basename(probe); + const cut = std.mem.lastIndexOf(u8, base, probe_name).?; + const prefix = base[0..cut]; + const suffix = base[cut + probe_name.len ..]; + var dir_buf: [detached_server.path_max:0]u8 = undefined; + const dir = std.fs.path.dirname(probe) orelse ""; + if (dir.len == 0 or dir.len >= dir_buf.len) { + end.* = .nothing_detached; + return null; + } + @memcpy(dir_buf[0..dir.len], dir); + dir_buf[dir.len] = 0; + const d = libc.opendir(dir_buf[0..dir.len :0]) orelse { + end.* = .nothing_detached; + return null; + }; + defer _ = libc.closedir(d); + var found: usize = 0; + var len: usize = 0; + while (libc.readdir(d)) |ent| { + const entry = std.mem.sliceTo(&ent.name, 0); + if (entry.len <= prefix.len + suffix.len) continue; + if (!std.mem.startsWith(u8, entry, prefix) or !std.mem.endsWith(u8, entry, suffix)) continue; + const name = entry[prefix.len .. entry.len - suffix.len]; + if (name.len > buf.len) continue; + found += 1; + @memcpy(buf[0..name.len], name); + len = name.len; + } + // A socket file whose session is gone still counts here: the sweep that + // unlinks corpses runs when the NEXT session binds (server.zig `sweep`), + // and probing every candidate with a connect would put a phantom frontend + // into a live session's slot table just to count it. One stale file + // therefore fails at `open` with "no such session", which is the truth. + if (found != 1) { + end.* = if (found == 0) .nothing_detached else .{ .ambiguous = found }; + return null; + } + return buf[0..len]; +} + +/// `--attach[=]`: the frontend half of a detached session. This process +/// owns a terminal and a socket, and the `Pardes` is in the session process +/// (src/detached/). The whole job is client.zig's two sentences — send the +/// input it collects, draw the frames it is sent — plus the real host work a +/// daemon has no way to do and asks a frontend for: fork a shell on a real tty, +/// put bytes on a real disk, reach a real clipboard. +/// +/// It is NOT a `Shell`, and the difference is not size. `Shell` IS the +/// `Host.ctx` of a core in THIS process, and its methods reach into that core +/// on nearly every line — a pane's message row, a watch generation taken off +/// the pane's live text, `acknowledgeShell`, `setCwd`. With no core those are +/// not cheaper versions of the same work, they are absent, and each one is +/// named below where it goes missing. What the two do genuinely share is +/// shared: the cell walk, the key and mouse vocabularies, the paste ceiling, +/// `forkShell`, `readPty`, `writeFileBytes`. +const Attach = struct { + io: std.Io, + gpa: std.mem.Allocator, + client: *detached_client.Client, + loop: *Loop, + vx: *vaxis.Vaxis, + tty: *vaxis.Tty, + prompt_rcs: *const shell_bin.PromptRcs, + paste_buf: *std.Io.Writer.Allocating, + /// Where the config directory came from is main.zig's answer, carried in + /// `Options` — the only field of it this frontend reads, since every other + /// one describes a core that is elsewhere. + config_dir: ?[]const u8, + ptys: [pardes.MAX_PANES]?Pty = @splat(null), + gens: [pardes.MAX_PANES]u32 = @splat(0), + inotify_fd: c_int, + watches: file_watch.Table = @splat(null), + /// What each watched slot is watching. `file_watch.Table` remembers the + /// directory mark and the accepted generation but not the pathname — the + /// in-process host reads that back off the pane, and this one has no panes, + /// so the path the `watch_file` message carried is kept here. + watch_paths: [pardes.MAX_PANES]?[]u8 = @splat(null), + watch_task: ?std.Io.Future(anyerror!void) = null, + caps_pending: bool = true, + check_files: bool = false, + in_paste: bool = false, + /// A frame landed. Painted once at the end of the round rather than where + /// it arrives: several can be decoded out of one poll and only the last of + /// them is on the screen. + dirty: bool = false, + + /// One event onto the wire. Non-null ends this frontend. + fn send(a: *Attach, ev: pardes.Event) ?AttachEnd { + a.client.send(.{ .event = ev }) catch |err| switch (err) { + // A message this protocol cannot carry, which is not a link that + // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB), + // and the one event that can reach it is a `file_changed` for a + // watched file between that and `look.readFile`'s own 256 MiB cap. + // Dropping it costs one reload; treating it as a hangup would cost + // the session. + error.Overlong, error.NoSpace => return null, + else => return .{ .lost = err }, + }; + return null; + } + + /// One decoded message from the session. Every arm of `wire.ServerMsg` is + /// named and none of them is a catch-all: a session goes on asking for what + /// it is not given, and the two this frontend genuinely cannot do say so + /// where they are handled rather than vanishing into an `else`. + fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd { + switch (msg) { + // Already applied to the client's slot and geometry; the full frame + // the session promises a fresh attach is the next thing to arrive. + .welcome => {}, + .refuse => |why| return .{ .refused = why }, + // Applied too — `grid` and `cursor` are current by the time this + // returns, so all that is left is to say the screen moved. + .frame => a.dirty = true, + .quit => return .none, + + .spawn => |v| a.spawn(v.pane, v.cwd), + .pty_write => |v| if (a.ptys[v.pane]) |pt| writeFd(pt.file.handle, v.bytes), + .pty_resize => |v| if (a.ptys[v.pane]) |pt| { + const ws: posix.winsize = .{ .row = v.rows, .col = v.cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); + }, + .write_file => |v| a.writeFile(v.pane, v.path, v.bytes), + // The dump lands on this frontend's disk. WHERE it landed is the + // core's own memory of it (`setLastDump`), and there is no + // `ClientTag` to carry a path back, so a detached `Dump` writes the + // file and the session cannot then name it. + .write_dump => |bytes| { + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf) orelse return null; + _ = writeFileBytes(path, bytes); + }, + .watch_file => |v| a.watchFile(v.pane, v.path, v.on), + // NOT DOABLE FROM HERE, and it is the wire's shape rather than an + // omission: this message carries `{generation, on}`, the theme + // file's PATH lives in the core (`themeFileRequest`), and there is + // no message that would carry the reloaded bytes back. So a + // detached session does not live-reload a theme file. Named + // anyway, because the alternative is an `else` that would also + // swallow the next arm somebody adds to the protocol. + .watch_theme => {}, + .dump_themes => a.dumpThemes(), + .set_clipboard => |text| if (text.len != 0) { + a.vx.copyToSystemClipboard(a.tty.writer(), text, a.gpa) catch {}; + }, + // The answer is not a reply message: it comes back as an ordinary + // `Event.paste`, which is the same asynchronous shape the + // in-process host has (see `Shell.readClipboard`), and it arrives + // through the `.paste` arm of `apply` like any other. + .read_clipboard => a.vx.requestSystemClipboard(a.tty.writer()) catch {}, + .open_link => |url| look.openLink(url), + } + return null; + } + + /// One vaxis event, translated onto the wire. Non-null ends the loop. + fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { + switch (event) { + // Nothing posts these here, and each absence has a reason. `tick` + // is `Shell.waitInput`'s animation clock, and an animation runs + // where the core is — the session sleeps on its own frame interval + // (server.zig `nap`) and the frames simply arrive. `fs_ready` + // belongs to `--fs`, which wire.zig keeps in the detached process. + // `lsp_done`/`pipe_done` answer work the core dispatches, and it + // dispatches it there. + .nop, .tick, .fs_ready, .lsp_done, .pipe_done => {}, + .quit => return .none, + .focus_in => {}, + .focus_out => return a.send(.pointer_leave), + .winsize => |ws| { + a.vx.resize(a.gpa, a.tty.writer(), ws) catch {}; + // Repaint from the frame already in hand: vaxis has just thrown + // its shadow grid away, and the SESSION grid may not move at + // all — it is the smallest common one and another frontend may + // be the small one (client.zig GEOMETRY). + a.dirty = true; + a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err }; + }, + .pty_read => |pr| { + defer a.gpa.free(pr.bytes); + return a.send(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + }, + .pty_eof => |e| if (a.gens[e.id] == e.gen) { + if (a.ptys[e.id]) |*pt| { + pt.reader.await(a.io) catch {}; // reader just finished; join it or its future leaks + _ = libc.close(pt.file.handle); + a.ptys[e.id] = null; + } + return a.send(.{ .eof = .{ .pane = @intCast(e.id) } }); + }, + .key_press => |key| if (a.in_paste) { + const bytes = pasteBytes(key); + const room = max_paste_bytes -| a.paste_buf.written().len; + a.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; + } else return a.send(keyEvent(key)), + .mouse => |m| if (mouseEvent(m)) |ev| return a.send(ev), + .paste => |bytes| { + defer a.gpa.free(@constCast(bytes)); + return a.send(.{ .paste = bytes }); + }, + .paste_start => { + a.in_paste = true; + a.paste_buf.clearRetainingCapacity(); + }, + .paste_end => { + a.in_paste = false; + defer a.paste_buf.clearRetainingCapacity(); + // ONE message for the whole paste, exactly as the in-process + // host makes it one `update`. + const pasted = a.paste_buf.written(); + if (pasted.len > 0) return a.send(.{ .paste = pasted }); + }, + .command => |line| { + defer a.gpa.free(line); + return a.send(.{ .command = line }); + }, + .files_changed => a.check_files = true, + } + return null; + } + + fn spawn(a: *Attach, pane: u8, cwd: []const u8) void { + // The in-process host's reaping rule, and its reason: the core reuses + // pane ids and there is no close effect, so a deleted pane's shell + // lives in its slot until a respawn lands here. + if (a.ptys[pane]) |*old| { + old.reader.cancel(a.io) catch {}; + _ = libc.close(old.file.handle); + a.ptys[pane] = null; + } + a.gens[pane] +%= 1; + var cwd_buf: [256:0]u8 = undefined; + var cwd_z: ?[*:0]const u8 = null; + if (cwd.len > 0 and cwd.len < cwd_buf.len) { + @memcpy(cwd_buf[0..cwd.len], cwd); + cwd_buf[cwd.len] = 0; + cwd_z = @ptrCast(&cwd_buf); + } + // The SESSION's grid, which is what its panes are laid out against; the + // pane's own size follows immediately as a `pty_resize`. + // + // `config.default_shell` and not the session's configured one: `Shell + // ` is a core setting, no message carries it, and inventing a + // second place that decides which shell runs would be worse than one + // that is occasionally the default. `shell_bin.resolve` falls back from + // there exactly as it does for a whole session. + const child = forkShell(null, pane, a.prompt_rcs, config.default_shell, cwd_z, a.client.rows, a.client.cols, null); + a.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; + // Unconditional, unlike `Shell.spawn`'s `threads_ok`: every spawn here + // arrives over a socket this loop is already running, so there is no + // pre-loop drain to be in. + if (a.ptys[pane]) |*pt| { + pt.reader = a.io.concurrent(readPty, .{ a.io, a.gpa, pt.file, @as(usize, pane), a.gens[pane], a.loop }) catch pt.reader; + } + } + + fn writeFile(a: *Attach, pane: u8, path: []const u8, bytes: []const u8) void { + if (!writeFileBytes(path, bytes)) return; + // Our own write is about to come back as a watch event: restamp from + // the bytes we just put there so it reads as "no change". Only when + // this IS the path this slot is watching — a `Save ` must + // not silence a real change to the file the pane has open. Same rule as + // `Shell.writeFile`; the comparison is against the path the session + // asked us to watch, because there is no pane here to ask. + // + // The "saved " message that host also writes is a pane's message + // row, which belongs to the core: a detached save is silent. + const watched = a.watch_paths[pane] orelse return; + if (!std.mem.eql(u8, watched, path)) return; + if (a.watches[pane]) |*w| w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }; + } + + fn watchFile(a: *Attach, pane: u8, path: []const u8, on: bool) void { + if (a.watch_paths[pane]) |old| a.gpa.free(old); + a.watch_paths[pane] = null; + if (!on or path.len == 0) return file_watch.watchPane(a.inotify_fd, &a.watches, pane, null, 0, .{ .text = 0 }); + const owned = a.gpa.dupe(u8, path) catch return; + // Seeded from what is on disk RIGHT NOW, so the first `file_changed` + // this sends is the first edit that is not already in the core. The + // in-process host takes the same hash off the pane's live text; that + // text is a socket away, and the file it came from is not. + var hash: u64 = 0; + if (look.readFile(a.gpa, owned)) |bytes| { + hash = std.hash.Wyhash.hash(0, bytes); + a.gpa.free(bytes); + } else |_| {} + a.watch_paths[pane] = owned; + file_watch.watchPane(a.inotify_fd, &a.watches, pane, owned, 0, .{ .text = hash }); + } + + /// A coalesced inotify wake: re-read every watched path and hand the + /// session the ones that really changed. It sends BYTES rather than + /// reloading anything, because the text belongs to the core — which is + /// exactly why `ClientTag` has a `file_changed` at all. + fn reloadWatched(a: *Attach) ?AttachEnd { + if (!a.check_files) return null; + a.check_files = false; + for (a.watch_paths, 0..) |slot, pane| { + const path = slot orelse continue; + const w = if (a.watches[pane]) |*entry| entry else continue; + const bytes = look.readFile(a.gpa, path) catch continue; + defer a.gpa.free(bytes); + const hash = std.hash.Wyhash.hash(0, bytes); + switch (w.generation) { + .text => |accepted| if (accepted == hash) continue, + // Never stored by this frontend: it cannot tell a PDF pane from + // a text one (the message carries a path and nothing else), so + // every slot is hashed and the core decides what the bytes mean + // — `applyWatchedFileChanged` reopens the path for a PDF pane + // and ignores them. + .pdf => {}, + } + // Committed here rather than after an acknowledgement, because + // there is none: `file_changed` is a one-way event like every other + // input on this wire. A snapshot the core rejects is therefore not + // retried until the file changes again — the same bound the + // in-process host lives with whenever a reload fails. + w.generation = .{ .text = hash }; + if (a.send(.{ .file_changed = .{ .pane = @intCast(pane), .bytes = bytes } })) |end| return end; + } + return null; + } + + /// The themes land on this frontend's disk. Where they went is reported on + /// a pane's message row by the in-process host, and that row is the core's, + /// so a detached dump is silent — the same shape as `write_dump` above. + fn dumpThemes(a: *Attach) void { + const dir = a.config_dir orelse return; + const out = user_config.dumpThemes(a.io, a.gpa, dir, pardes.themes) catch return; + a.gpa.free(out); + } + + /// The capability handshake, resolved on the loop exactly as + /// `Shell.pollFrame` resolves it and for its reason: the replies land on + /// vaxis's reader thread, and this is the only thread allowed to write to + /// the tty. No `native_images` here — this wire carries no attachments. + fn enableCaps(a: *Attach) void { + if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return; + a.caps_pending = false; + a.vx.enableDetectedFeatures(a.tty.writer()) catch {}; + // Earlier frames were drawn under the pre-handshake caps, and vaxis's + // shadow grid has to be re-established under the new ones or it keeps + // skipping cells it thinks are current. + a.vx.queueRefresh(); + a.dirty = true; + } + + fn paint(a: *Attach) void { + a.dirty = false; + const win = a.vx.window(); + // The session grid can be smaller than this window; `paintCells` clears + // first, so the surplus is the terminal's own default cell rather than + // whatever was there a frame ago. + paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); + if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); + a.vx.render(a.tty.writer()) catch {}; + } +}; + +/// How long the frontend may sleep on the socket before it looks at the +/// terminal. This loop has TWO event sources and can block on only one of +/// them: vaxis delivers the terminal's events on its reader thread into a +/// mutex/condvar queue, which has no descriptor to hand `poll(2)` alongside +/// the socket — client.zig's `wait` takes a timeout for exactly that reason +/// ("the terminal it draws on is polled by whoever owns that"), and this is +/// whoever. +/// +/// 8 ms is half a 60 Hz frame: a keystroke waits at most one of those before it +/// is on the wire (4 ms on average), and the frame it causes needs no wait at +/// all — it lands in the poll the moment the session writes it. The price of +/// the ceiling is 125 poll rounds a second on a frontend nobody is touching, +/// and it is measurably below the noise of what an idle pardes already costs: +/// on this machine (i7-11700, 100 Hz jiffies) an idle attached frontend used +/// 0.16% of one core over 60 s and 0.18% over 120 s, against 0.11% and 0.31% +/// for an idle in-process session on the same screen over the same windows. +/// Reach for an eventfd and a waker thread — fuse.zig's `pollLoop` is the +/// pattern — only if that ever stops being true. +const attach_poll_ms = 8; + +/// The whole `--attach` session: connect, then one loop over the two event +/// sources until the session, the link or the terminal ends it. The terminal is +/// already raw, on the alt screen and reporting the mouse — `run` did that, and +/// `run`'s defers undo it, which is what makes an attach leave a terminal in +/// exactly the state an ordinary exit does. +fn attachSession(init: std.process.Init, opts: pardes.Options, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd { + const io = init.io; + const gpa = init.gpa; + + // The hello carries this window, so the size has to be real before it goes + // out: a session told 80x24 by a 200x50 terminal reflows every pane twice, + // once now and once on the first SIGWINCH. `vx.resize` here rather than + // waiting for the loop's first event for the same reason — the first frame + // may arrive before any terminal event does, and it has to have somewhere + // to be painted. + const ws = tty.getWinsize() catch |err| return .{ .lost = err }; + if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize }; + vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err }; + + var client = detached_client.Client.open(gpa, name, ws.cols, ws.rows) catch |err| return switch (err) { + error.NoSession, error.NoSessionPath => .{ .no_session = name }, + // The connect landed and the session hung up during the hello. That is + // what a refusal AT ACCEPT TIME looks like from here: server.zig's + // `refuseFd` writes six bytes and closes in the same pass, so the close + // can beat our hello onto the socket and `open` never gets far enough + // to read the reason. A refusal we do read arrives as `.refused` with + // the reason in it. + error.Closed => .rejected, + else => .{ .lost = err }, + }; + // `detach` and not `deinit`: seven bytes that turn "the peer vanished" into + // "the peer left" in the session's log. + defer client.detach(); + + var loop: Loop = .init(io, tty, vx); + var paste_buf: std.Io.Writer.Allocating = .init(gpa); + defer paste_buf.deinit(); + // Private and complete before any fork, exactly as in `run`: the pane + // shells this frontend is asked to spawn borrow these stable path buffers. + var prompt_rcs = shell_bin.PromptRcs.init(); + defer prompt_rcs.deinit(); + + var a: Attach = .{ + .io = io, + .gpa = gpa, + .client = &client, + .loop = &loop, + .vx = vx, + .tty = tty, + .prompt_rcs = &prompt_rcs, + .paste_buf = &paste_buf, + .config_dir = opts.config_dir, + .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1, + }; + // Registered BEFORE `loop.stop()` below so LIFO runs it after: the reader + // has to be joined before the queue is emptied, or a late post lands in a + // queue nobody drains again and its bytes leak. `run`'s teardown has the + // same shape and the same order, minus the workers this frontend never + // starts. + defer { + for (&a.ptys) |*slot| if (slot.*) |*pt| { + pt.reader.cancel(io) catch {}; + _ = libc.close(pt.file.handle); + slot.* = null; + }; + if (a.watch_task) |*t| { + t.cancel(io) catch {}; + a.watch_task = null; + } + if (a.inotify_fd >= 0) { + _ = libc.close(a.inotify_fd); + a.inotify_fd = -1; + } + for (&a.watch_paths) |*slot| if (slot.*) |p| { + gpa.free(p); + slot.* = null; + }; + while (loop.tryEvent() catch null) |ev| switch (ev) { + .pty_read => |pr| gpa.free(pr.bytes), + .command => |line| gpa.free(line), + .paste => |b| gpa.free(@constCast(b)), + else => {}, + }; + } + + // A pardes started inside one of THIS frontend's pane shells finds this + // process as its outer instance — the shells are our children — so the + // nested-instance listener belongs here and not in the session, which has + // no children at all. The command line it accepts goes over the wire as an + // `Event.command`, which is what `ClientTag.command` is for. + const sock_fd: c_int = if (opts.nested) -1 else nested.listen(); + defer nested.unlisten(sock_fd); + + loop.start() catch |err| return .{ .lost = err }; + defer loop.stop(); + // Both detached threads, for `run`'s reasons: sigwait and accept4 never + // return, so an `io.concurrent` task around either would hang the teardown + // that joins it. + (std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach(); + if (sock_fd >= 0) (std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, &loop }) catch |err| return .{ .lost = err }).detach(); + if (a.inotify_fd >= 0) a.watch_task = io.concurrent(watchFiles, .{ io, a.inotify_fd, &loop }) catch null; + // Send the capability probes and do not wait on them; `Attach.enableCaps` + // resolves them on the loop. run() has the long version of why. + vx.queryTerminalSend(tty.writer()) catch {}; + + while (true) { + const link = a.client.wait(attach_poll_ms); + // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in + // the same call that read the last bytes, and the last bytes are the + // session's `quit`: `fill` appends every chunk and only then sees the + // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly + // this reason, and honouring that means draining what arrived before + // deciding the link is what ended us — otherwise an ordinary `Kill` + // exits one frontend 0 (it got the quit alone) and whichever frontend + // was in the same poll round nonzero, which is what the first run of + // this loop actually did. + while (true) { + const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break; + if (a.handle(msg)) |end| return end; + } + link catch |err| return .{ .lost = err }; + // The terminal, drained the way `Shell.waitInput` drains it and for its + // reason: a wheel flick is one batch rather than fifty round trips, and + // a paste in flight keeps draining without a message per character. + var batch: usize = 0; + while (a.in_paste or batch < 64) { + // Propagated rather than swallowed, unlike `Shell.waitInput`'s + // identical drain: there the blocking `nextEvent` above it is what + // notices a dead event source, and here there is no blocking read + // to notice with. + const ev = (loop.tryEvent() catch |err| return .{ .lost = err }) orelse break; + batch += 1; + if (a.apply(ev)) |end| return end; + } + if (a.reloadWatched()) |end| return end; + a.enableCaps(); + if (a.dirty) a.paint(); + } +} -- cgit v1.3