summaryrefslogtreecommitdiff
path: root/src/fs_service.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/fs_service.zig')
-rw-r--r--src/fs_service.zig270
1 files changed, 270 insertions, 0 deletions
diff --git a/src/fs_service.zig b/src/fs_service.zig
new file mode 100644
index 00000000..6fdb9991
--- /dev/null
+++ b/src/fs_service.zig
@@ -0,0 +1,270 @@
+//! `pardes --fs`: what a native HOST has to decide to serve acme's control
+//! filesystem. `acmefs.zig` owns the semantics and `fuse.zig` owns the kernel;
+//! what is left, and lives here, is three decisions — WHERE to mount (derive a
+//! per-session point, or take the one the user named), WHEN to drain (one
+//! frame's batch, in the order fuse.zig's two queues require), and WHAT A PANE
+//! SHELL IS TOLD about it (`PARDES_FS`/`PARDES_PANE`, exported before the
+//! fork).
+//!
+//! It exists because tty.zig and gui.zig would otherwise each carry the same
+//! forty lines through two different loops; the only thing that genuinely
+//! differs between them is how a background thread wakes the loop, and that is
+//! a function pointer. A session without `--fs` allocates nothing here, starts
+//! no thread, and costs one null check per frame.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("pardes.zig");
+const fuse = @import("fuse.zig");
+
+/// Diagnostics land on a pane's message row, not on stderr: in the tty shell
+/// stderr IS the screen (see main.zig's logFn, which drops every scope for
+/// exactly that reason). The log line is the `PARDES_LOG=1` copy, where the
+/// mount point and the errno name are worth having.
+const log = std.log.scoped(.fs);
+
+// std.c has getenv but neither setter, same as nested.zig.
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+
+/// How many kernel requests one frame will answer before handing the loop back
+/// to the renderer. A `find $PARDES_FS` or a script in a `while true` loop can
+/// produce them faster than a frame takes, and an uncapped drain would render
+/// only when the script paused. Hitting the cap is not a stall: `drain` says so
+/// and the caller wakes its own loop, so the batch continues on the next pass
+/// with one frame drawn in between.
+const max_batch = 64;
+
+/// Where per-session mounts live: `$XDG_RUNTIME_DIR/pardes` else
+/// `~/.local/state/pardes`, and `<that>/<pid>` is this session's mount point.
+///
+/// NOT `nested.socketDir`, though it answers a related question. That one
+/// returns `$XDG_RUNTIME_DIR` itself, because a socket is a FILE whose name
+/// (`pardes-<pid>.sock`) already namespaces it. A mount point is a DIRECTORY
+/// per pid, and `fuse.sweepStale` unmounts and removes every `<digits>` entry
+/// it finds — so it needs a parent that contains nothing but our mounts, which
+/// under `$XDG_RUNTIME_DIR` means one more level. The HOME fallback already has
+/// that level, which is why the two strings coincide there and only there.
+/// The two strings are parameters rather than `getenv` calls so the tests below
+/// need not mutate the process environment. That is not fastidiousness: a test
+/// binary shares one environ, and unsetting HOME here once took down an
+/// unrelated subprocess test three files away.
+fn parentFrom(buf: *[std.fs.max_path_bytes:0]u8, xdg: ?[]const u8, home: ?[]const u8) ?[:0]const u8 {
+ if (xdg) |x| return std.fmt.bufPrintSentinel(buf, "{s}/pardes", .{x}, 0) catch null;
+ const h = home orelse return null;
+ return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{h}, 0) catch null;
+}
+
+fn envSlice(name: [*:0]const u8) ?[]const u8 {
+ return if (libc.getenv(name)) |v| std.mem.span(v) else null;
+}
+
+fn parentDir(buf: *[std.fs.max_path_bytes:0]u8) ?[:0]const u8 {
+ return parentFrom(buf, envSlice("XDG_RUNTIME_DIR"), envSlice("HOME"));
+}
+
+/// The mount point itself, from `Options.fs`: EMPTY means a bare `--fs`, so
+/// derive `<parent>/<pid>`, and anything else is the `--fs=<dir>` the user
+/// named, which wins verbatim — scripts and the snapshot harness need a name
+/// they can predict.
+///
+/// A named point must be absolute for the reason fuse.zig gives: the path is
+/// handed to a setuid helper that resolves it against its OWN cwd, so a
+/// relative one names somewhere else. Passing it through unresolved rather than
+/// rooting it here keeps that one rule in one place; `Fs.mount` returns
+/// `error.MountPathNotAbsolute`.
+fn mountPoint(buf: *[std.fs.max_path_bytes:0]u8, named: []const u8, parent: ?[]const u8) ?[:0]const u8 {
+ if (named.len != 0) return std.fmt.bufPrintSentinel(buf, "{s}", .{named}, 0) catch null;
+ const dir = parent orelse return null;
+ // unsigned: {d} prints a leading '+' for a positive SIGNED int
+ return std.fmt.bufPrintSentinel(buf, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }, 0) catch null;
+}
+
+/// Sweep, derive, mount. Null when the session did not ask for a filesystem —
+/// and also when it asked and the mount failed, which is deliberately the same
+/// answer: a missing `fuse3`, a `user_allow_other`-less config or a kernel
+/// without FUSE must cost the user their scripting, never their session. The
+/// failure is reported once, on pane 0's message row, and everything else runs.
+///
+/// Call after the core exists and before the first frame: the mount is live the
+/// moment it returns, so a script racing startup finds a filesystem whose panes
+/// are already there.
+pub fn start(gpa: std.mem.Allocator, core: *pardes.Pardes) ?*fuse.Fs {
+ const named = core.opts.fs orelse return null;
+ var parent_buf: [std.fs.max_path_bytes:0]u8 = undefined;
+ const parent = parentDir(&parent_buf);
+ var buf: [std.fs.max_path_bytes:0]u8 = undefined;
+ const point = mountPoint(&buf, named, parent) orelse {
+ core.reportError(0, "fs mount", error.NoRuntimeDirectory);
+ return null;
+ };
+ // Both of these are about a point we DERIVED. A `--fs=<dir>` the user named
+ // is not a directory we are entitled to unmount other things out of, its
+ // siblings are not ours to guess about, and it is not ours to remove on the
+ // way out either — `owns_dir` is what keeps `Fs.deinit` from rmdir'ing a
+ // directory the user made.
+ const derived = named.len == 0;
+ if (derived) if (parent) |dir| fuse.sweepStale(dir);
+ const fs = fuse.Fs.mount(gpa, .{ .mount = point, .owns_dir = derived }) catch |err| {
+ log.warn("--fs: cannot mount at {s}: {t}", .{ point, err });
+ core.reportError(0, "fs mount", err);
+ return null;
+ };
+ log.info("--fs: serving {s}", .{point});
+ return fs;
+}
+
+/// Start the one background thread, if there is a filesystem to start it for.
+/// It waits for POLLIN on `/dev/fuse` and calls `wake(ctx)` — nothing else; it
+/// never touches the core, the descriptor's data, or a request. Both hosts pass
+/// a one-line callback that posts their own wake event, which is the ONLY thing
+/// that differs between them here.
+///
+/// A thread that will not spawn is not a filesystem that will not work: the
+/// frame poll drains the same requests either way, so the loss is wake latency
+/// (a script waits for the next event to arrive from anywhere) and the session
+/// is not worth failing over it. That is also the documented no-parallelism
+/// backend: skip this call entirely and everything still works.
+pub fn wake(fs: ?*fuse.Fs, ctx: ?*anyopaque, callback: *const fn (?*anyopaque) void) void {
+ const f = fs orelse return;
+ f.wakeThread(ctx, callback) catch |err|
+ log.warn("--fs: no poll thread ({t}); draining once per frame instead", .{err});
+}
+
+/// What one frame's worth of filesystem work amounted to. Two separate facts,
+/// because the two hosts need different ones: an interactive loop asks whether
+/// to re-arm itself, while the headless grid harness asks whether anything
+/// happened at all — its contract is one frame per event, and a request that
+/// changed a pane IS an event.
+pub const Drained = struct {
+ /// Requests answered, parked retries included.
+ count: usize = 0,
+ /// The cap stopped the batch with requests still waiting in the kernel.
+ pending: bool = false,
+};
+
+/// One frame's worth of filesystem work.
+///
+/// The two loops are both to null and in this order, which is fuse.zig's
+/// contract rather than a preference:
+///
+/// - `retry()`'s null ENDS AND RESETS the round, so a caller that took one
+/// parked request per frame would leave the second-oldest blocked reader
+/// waiting 32 frames. The round is bounded by the park table, so it needs
+/// no cap of its own.
+/// - `next()`'s null is what acknowledges the drain to the poll thread. That
+/// handshake is what stops a level-triggered `poll()` from spinning a core,
+/// which is why `pending` has to keep the loop hot: no ack has been sent,
+/// so nothing else will wake us.
+pub fn drain(fs: *fuse.Fs, core: *pardes.Pardes) Drained {
+ var d: Drained = .{};
+ while (fs.retry()) |req| {
+ step(fs, core, req);
+ d.count += 1;
+ }
+ while (d.count < max_batch) {
+ const req = fs.next() orelse return d;
+ step(fs, core, req);
+ d.count += 1;
+ }
+ d.pending = true;
+ return d;
+}
+
+/// One request, one answer, and nothing in between: `req.data` borrows storage
+/// the next `next()` overwrites, and the `.fs_reply` this emits is drained
+/// before the loop can move on — so the borrow window is a single step, exactly
+/// as the design contract requires. The reply normally reaches `Fs.reply`
+/// through the host's `push_fs_reply`, because the payload bytes are resolved
+/// by `pardes.fsPayload` inside `perform` and are only valid there.
+///
+/// The exception is the `if` at the end. The core's effect ring is bounded and
+/// `emit` DROPS on overflow, which for every other effect costs a repaint and
+/// for this one costs a foreign process: an unanswered FUSE request leaves its
+/// writer in uninterruptible sleep and its park slot used forever, and 32 of
+/// those make the whole mount answer EAGAIN. One `ctl` write reaches the cap
+/// (`put` emits a `.save_file` per line). So this loop, which is the only place
+/// that knows a request is outstanding, watches the effects it performs for the
+/// answer and invents an EIO when none came.
+fn step(fs: *fuse.Fs, core: *pardes.Pardes, req: pardes.acmefs.Req) void {
+ core.update(.{ .fs_req = req });
+ var answered = false;
+ while (core.nextEffect()) |e| {
+ if (e == .fs_reply and e.fs_reply.tag == req.tag) answered = true;
+ core.perform(e);
+ }
+ if (!answered) {
+ const eio = pardes.acmefs.Reply.fail(req.tag, pardes.acmefs.E.IO);
+ fs.reply(&eio, "");
+ }
+}
+
+/// What a pane shell is told about the filesystem: `PARDES_FS` is the mount and
+/// `PARDES_PANE` is this pane's serial, so a script run inside a pane addresses
+/// its own window with no arguments. That pair is acme's `winid` (exec.c), and
+/// the serial rather than the slot index because slots are reused and serials
+/// never are — `$PARDES_FS/$PARDES_PANE/body` must not start naming somebody
+/// else's pane after a close.
+///
+/// Exported in the PARENT, immediately before the fork, and this is the one
+/// place pardes cannot copy acme. acme calls `putenv` in the child, which is
+/// safe there because `rfork(RFENVG)` has just given that child a private
+/// environment group. A Linux fork has no such thing, and `setenv` between fork
+/// and exec can deadlock on an allocator lock some other thread held at fork
+/// time — the same rule that already forces `shell_bin.resolve` above the fork
+/// in both hosts. The cost is that pardes's own environ carries the
+/// last-spawned pane's number; nothing in pardes reads it, and a subprocess
+/// that inherits it was spawned on behalf of a pane anyway.
+///
+/// With no filesystem the pair is REMOVED rather than left alone. A pardes
+/// started inside a pardes that does serve one inherits both variables from its
+/// parent's pane shell, and a session with no mount of its own must not hand
+/// its panes an address that resolves to a window in someone else's session.
+pub fn exportPaneEnv(fs: ?*const fuse.Fs, serial: u32) void {
+ const f = fs orelse {
+ _ = unsetenv("PARDES_FS");
+ _ = unsetenv("PARDES_PANE");
+ return;
+ };
+ _ = setenv("PARDES_FS", f.path.ptr, 1);
+ var buf: [16:0]u8 = undefined;
+ const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch return;
+ _ = setenv("PARDES_PANE", id.ptr, 1);
+}
+
+const testing = std.testing;
+
+test "the mount point is one level below a per-user parent, named by our pid" {
+ // $XDG_RUNTIME_DIR is shared with every other program in the session, so
+ // the mounts need a `pardes/` of their own under it — the level
+ // nested.socketDir does not have, and the reason this is not that function.
+ // Asserted rather than merely described, because `fuse.sweepStale` unmounts
+ // and removes every `<digits>` entry in whatever directory it is handed.
+ var parent: [std.fs.max_path_bytes:0]u8 = undefined;
+ const dir = parentFrom(&parent, "/run/user/1000", "/home/tester").?;
+ try testing.expectEqualStrings("/run/user/1000/pardes", dir);
+ var buf: [std.fs.max_path_bytes:0]u8 = undefined;
+ var expect: [std.fs.max_path_bytes]u8 = undefined;
+ try testing.expectEqualStrings(
+ try std.fmt.bufPrint(&expect, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }),
+ mountPoint(&buf, "", dir).?,
+ );
+}
+
+test "no XDG_RUNTIME_DIR falls back to the home state directory, which has the level already" {
+ var parent: [std.fs.max_path_bytes:0]u8 = undefined;
+ try testing.expectEqualStrings(
+ "/home/tester/.local/state/pardes",
+ parentFrom(&parent, null, "/home/tester").?,
+ );
+}
+
+test "a session with no filesystem removes an inherited address rather than passing it on" {
+ // PARDES_FS/PARDES_PANE are ours alone, and this leaves them the way an
+ // --fs-less session leaves them: absent. Nothing else in the test binary
+ // reads either name, which is why this is the one env-touching test here.
+ _ = setenv("PARDES_FS", "/run/user/1000/pardes/999", 1);
+ _ = setenv("PARDES_PANE", "7", 1);
+ exportPaneEnv(null, 3);
+ try testing.expect(libc.getenv("PARDES_FS") == null);
+ try testing.expect(libc.getenv("PARDES_PANE") == null);
+}