From 6f48508aa08396bcf9dd4da2cab1d221bcc53f78 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 25 Aug 2026 02:07:23 -0300 Subject: acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSE --- src/tty/tty.zig | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 2 deletions(-) (limited to 'src/tty') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 404fe582..c5abf0fe 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -17,6 +17,8 @@ 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 panel_compositor = @import("panel_compositor.zig"); const host_api = @import("../host.zig"); @@ -61,6 +63,14 @@ pub const Command = struct { /// 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)); @@ -521,6 +531,24 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); + // `--fs`: mount before the initial spawns, because those shells are the + // ones that need PARDES_FS in their environment, and before the first + // 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 `/`. 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 only: the ordinary exit unmounts at the END OF + // THE LOOP instead, see there. + defer if (fs) |f| f.deinit(); + var sh: Shell = .{ .io = io, .gpa = gpa, @@ -538,6 +566,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // mark the file a positional path argument opened. -1 off linux: // watchPane goes quiet and the core simply never gets a file_changed. .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1, + .fs = fs, }; defer { // reap the reader tasks (cancel interrupts a blocked read) before @@ -604,6 +633,11 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // 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 @@ -705,6 +739,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } } } + // 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 `/event` when the last pane is deleted through + // `ctl` then gets ENOTCONN at once instead of hanging until somebody + // touches the keyboard. + if (fs) |f| { + f.deinit(); + fs = null; + sh.fs = null; + } } /// Everything the terminal shell owns and the core cannot: the ptys, the @@ -727,6 +773,10 @@ const Shell = struct { /// 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, 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) @@ -790,6 +840,7 @@ const Shell = struct { .push_open_link = openLink, .pull_lsp = lsp, .pull_pipe = pipe, + .push_fs_reply = fsReply, }; // ---- input ------------------------------------------------------------ @@ -960,6 +1011,12 @@ const Shell = struct { // `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); @@ -995,6 +1052,18 @@ const Shell = struct { 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, 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. + _ = 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 @@ -1156,7 +1225,7 @@ const Shell = struct { cwd_buf[cwd.len] = 0; cwd_z = @ptrCast(&cwd_buf); } - const child = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w); + const child = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; // report the pane's starting directory back to the core (tags). The // slot needs no occupancy reset: nothing is remembered, and the next @@ -1246,6 +1315,17 @@ 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; @@ -1454,12 +1534,27 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { +/// 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 {}; +} + +fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) struct { file: std.Io.File, pid: posix.pid_t } { var master: c_int = undefined; // resolved BEFORE the fork, into this frame, which the child inherits: // nothing between fork and exec may allocate, and a PATH search would var path_buf: [std.fs.max_path_bytes]u8 = undefined; const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs); + // ...and so is the pane's own address on the control filesystem, for a + // second reason on top of that one: acme puts `winid` in the child, which + // is safe there only because rfork(RFENVG) has just given it a private + // environment group. See fs_service.exportPaneEnv. + fs_service.exportPaneEnv(fs, if (core.panes[pane]) |pn| pn.serial else 0); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { -- cgit v1.3