summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 02:07:23 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:07 -0300
commit6f48508aa08396bcf9dd4da2cab1d221bcc53f78 (patch)
tree83daec3db5ac27ea3172651df2b3e9cb62eddb4a /src/gui
parent28c70cabb6ceb7e5fecfd74f6984f5a995269f01 (diff)
downloadpardes-6f48508aa08396bcf9dd4da2cab1d221bcc53f78.tar.gz
pardes-6f48508aa08396bcf9dd4da2cab1d221bcc53f78.zip
acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSE
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/gui.zig96
1 files changed, 93 insertions, 3 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index e080dd9a..092ac2e4 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -26,6 +26,8 @@ const selection_pipe = @import("../selection_pipe.zig");
const shell_bin = @import("../shell_bin.zig");
const nested = @import("../nested.zig");
+const fuse = @import("../fuse.zig");
+const fs_service = @import("../fs_service.zig");
pub const c = @cImport({
@cDefine("SDL_DISABLE_OLD_NAMES", "1");
@@ -851,6 +853,12 @@ const Msg = union(enum) {
/// a pardes launched inside this one sent us a builtin command line (see
/// lookThread); gpa-owned, like `output` bytes
command: []u8,
+ /// `--fs`: the /dev/fuse descriptor has requests on it. Carries nothing —
+ /// the drain lives in pollFrame, and this only ends a blocking
+ /// SDL_WaitEventTimeout. Posted by the poll thread and, when a batch hits
+ /// its cap, by pollFrame itself. Lossy under backpressure on purpose: a
+ /// full queue already holds something that will wake the loop.
+ fs_ready,
fn deinit(m: Msg, gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator) void {
switch (m) {
@@ -861,7 +869,7 @@ const Msg = union(enum) {
response.deinit(gpa);
},
.command => |line| gpa.free(line),
- .eof, .files_changed => {},
+ .eof, .files_changed, .fs_ready => {},
}
}
};
@@ -1903,6 +1911,18 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
defer nested.unlisten(sock_fd);
+ // `--fs`: mounted before the initial spawns (they are the shells that need
+ // PARDES_FS) and before any thread of ours exists (the mount forks the
+ // setuid fusermount3 helper). 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 without a filesystem. Teardown answers everything
+ // held, aborts the connection, unmounts and removes `<parent>/<pid>`; the
+ // parent stays, like nested.zig's socket directory.
+ 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 shell: Shell = .{
.core = core,
.gui = &g,
@@ -1916,6 +1936,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.pipe_tasks = &pipe_tasks,
.inotify_fd = inotify_fd,
.watches = &watches,
+ .fs = fs,
.test_mode = test_mode,
};
const host = shell.host();
@@ -1935,6 +1956,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// ...and the nested-instance listener, detached like every other blocking
// worker here
if (sock_fd >= 0) if (std.Thread.spawn(.{}, lookThread, .{ gpa, sock_fd, &queue })) |th| th.detach() else |_| {};
+ // ...and the /dev/fuse poller, which is the same kind of thread again —
+ // except joined by `Fs.deinit` rather than detached, because fuse.zig gives
+ // it a control pipe that CAN wake it out of poll().
+ fs_service.wake(fs, &queue, wakeFs);
_ = c.SDL_StartTextInput(window);
@@ -1987,6 +2012,16 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
syncTaglineFont(&g, core);
}
}
+
+ // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below: a
+ // session that has decided to exit must not spend its teardown holding a
+ // mount nobody is serving, so a client blocked on `<id>/event` when the
+ // last pane is deleted through `ctl` gets ENOTCONN at once.
+ if (fs) |f| {
+ f.deinit();
+ fs = null;
+ shell.fs = null;
+ }
}
/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in,
@@ -2059,6 +2094,11 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
// scripted input event, and a reload that arrives on its own clock would
// put a frame in the stream nothing asked for. -1 makes watchPane a no-op.
var watches: file_watch.Table = @splat(null);
+ // The filesystem IS served here, unlike the file watcher above: `--fs=<dir>`
+ // names a predictable mount point precisely so a snapshot can drive this
+ // mode through it. No poll thread though — see gridPollFrame.
+ const fs = fs_service.start(gpa, core);
+ defer if (fs) |f| f.deinit();
var shell: Shell = .{
.core = core,
.io = io,
@@ -2071,6 +2111,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
.pipe_tasks = &pipe_tasks,
.inotify_fd = -1,
.watches = &watches,
+ .fs = fs,
};
const host = shell.host();
// The core owns the loop here too, but not the scripted stdin: EOF ends
@@ -2997,6 +3038,10 @@ const Shell = struct {
gui: ?*Gui = null,
io: std.Io,
gpa: std.mem.Allocator,
+ /// acme's control filesystem for this session, or null when `--fs` was not
+ /// given (or its mount failed, or this is the headless grid harness, which
+ /// serves nothing). Owned by `run`.
+ fs: ?*fuse.Fs = null,
lsp_allocator: std.mem.Allocator,
prompt_rcs: *const shell_bin.PromptRcs,
ptys: *[pardes.MAX_PANES]?Pty,
@@ -3048,6 +3093,7 @@ const Shell = struct {
.push_open_link = openLink,
.pull_lsp = lsp,
.pull_pipe = pipe,
+ .push_fs_reply = fsReply,
};
/// The headless grid harness. It reads its scripted stdin itself, because
@@ -3110,12 +3156,34 @@ const Shell = struct {
// `git checkout`) collapses into ONE pass below, so it cannot queue
// a reload — or an undo entry — per write.
.files_changed => check_files = true,
+ // A wake and nothing more; the requests behind it are drained in
+ // pollFrame, which is where a whole batch can be answered against
+ // one render instead of one render per request.
+ .fs_ready => {},
};
if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches))
s.queue.push(.files_changed);
}
};
+/// The core's answer to one filesystem request, handed straight back to the
+/// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside
+/// `perform` and is borrowed only for this call, so a megabyte body read copies
+/// nothing. `.again` needs no 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 = shellOf(ctx);
+ if (s.fs) |f| f.reply(reply, bytes);
+}
+
+/// The /dev/fuse poller's wake. `Queue.push` is the thread-safe door and
+/// already raises the SDL user event that ends a blocking WaitEventTimeout, so
+/// this is the whole callback — the same shape as watchThread's.
+fn wakeFs(ctx: ?*anyopaque) void {
+ const q: *Queue = @ptrCast(@alignCast(ctx.?));
+ q.push(.fs_ready);
+}
+
fn shellOf(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
}
@@ -3159,6 +3227,14 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
fn pollFrame(ctx: ?*anyopaque) void {
const s = shellOf(ctx);
const core = s.core;
+ // acme's filesystem: one batch per frame, answered before anything else in
+ // the pass, so an edit a script just made through `body` is in the surface
+ // this frame composes. Ahead of the `s.gui orelse return` below because it
+ // has nothing to do with pixels. Hitting the cap means no ack reached the
+ // poll thread, so nothing else will wake us — re-arm the loop ourselves;
+ // `Queue.push` is lossy for this variant, which is correct, because a queue
+ // too full to take a wake is already holding one.
+ if (s.fs) |f| if (fs_service.drain(f, core).pending) s.queue.push(.fs_ready);
const g = s.gui orelse return;
// TaglineSize is pure renderer state: update the smaller face and its
// visual band immediately, without changing the body metrics or grid.
@@ -3257,6 +3333,16 @@ fn postPresent(ctx: ?*anyopaque) void {
/// animation step, and the text of the grid itself.
fn gridPollFrame(ctx: ?*anyopaque) void {
const s = shellOf(ctx);
+ // The filesystem, drained on the pass rather than woken by a thread: this
+ // mode's contract is one frame per scripted event, and a poller posting on
+ // its own clock would put frames in the stream nothing asked for. fuse.zig
+ // supports exactly this — skip `wakeThread` and drain from the frame poll —
+ // and here it is not a degradation but the point. A request that changed
+ // something IS an event, so the pass renders: that is what lets a snapshot
+ // `wait` for text a script wrote through the mount.
+ if (s.fs) |f| {
+ if (fs_service.drain(f, s.core).count != 0) s.saw_event = true;
+ }
pollCwds(s.core, s.ptys);
// The harness polls stdin at the same 16 ms cadence as native SDL, so a
// pass IS a frame interval and the tick is due here rather than behind a
@@ -3307,7 +3393,7 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const pt = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w);
+ const pt = 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] = pt;
// report the pane's starting directory back to the core (tags); the slot
// needs no occupancy reset, nothing about it is remembered
@@ -3448,12 +3534,16 @@ fn pipe(ctx: ?*anyopaque, id: u32) void {
if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id);
}
-fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
+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) Pty {
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: 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) {