diff options
Diffstat (limited to 'src/fs_service.zig')
| -rw-r--r-- | src/fs_service.zig | 270 |
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); +} |
