//! `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 `/` 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-.sock`) already namespaces it. A mount point is a DIRECTORY /// per pid, and `fuse.sweepStale` unmounts and removes every `` 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 `/`, and anything else is the `--fs=` 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=` 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 `` 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); }