diff options
Diffstat (limited to 'src/fs_service.zig')
| -rw-r--r-- | src/fs_service.zig | 324 |
1 files changed, 0 insertions, 324 deletions
diff --git a/src/fs_service.zig b/src/fs_service.zig deleted file mode 100644 index b440c2c2..00000000 --- a/src/fs_service.zig +++ /dev/null @@ -1,324 +0,0 @@ -//! `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; - -/// WHAT A TRANSPORT IS, to this file: three functions and a pointer. -/// -/// `drain` and `step` below never asked a `*fuse.Fs` for anything else — -/// `retry()`, `next()` and `reply()` are the whole of it — so the concrete -/// pointer was a coupling that bought nothing and forbade a second answer. -/// Naming the three makes the seam a thing a reader can see, and makes a 9P -/// listener beside the mount a matter of writing one more implementation -/// rather than of teaching this file about it. -/// -/// It is deliberately NOT a Zig interface with `anytype`: `drain` is ONE -/// function reached from four call sites (tty.zig, gui.zig twice, and the -/// daemon), and the second transport this seam exists for is chosen at RUN -/// time, so it has to be a value with a runtime type — which is a vtable, the -/// same shape and the same reasoning as `host.VTable`. Nothing holds a -/// `Transport` across a frame: every caller builds one inline from whatever it -/// has, which is why the thunks matter and the struct does not. -/// -/// The ORDER contract stays where it was, in `drain`, because it belongs to -/// the caller rather than to any implementor: `retry()` to null first, then -/// `next()` to null. An implementation with no parking answers `retry` null -/// forever and loses nothing. -pub const Transport = struct { - ctx: *anyopaque, - vtable: *const VTable, - - pub const VTable = struct { - /// The oldest parked request that is worth offering again, or null when - /// the round is over. Null also RESETS the round — see `drain`. - retry: *const fn (ctx: *anyopaque) ?pardes.acmefs.Req, - /// The next request off the wire, or null when there is nothing more. - /// That null is also the acknowledgement some transports owe a poller, - /// so a caller must reach it rather than stopping early. - next: *const fn (ctx: *anyopaque) ?pardes.acmefs.Req, - /// Answer one request. `bytes` is borrowed for the duration of the - /// call only. A `.again` status is the transport's business, not the - /// caller's: it re-parks the request itself. - reply: *const fn (ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void, - }; - - pub fn retry(t: Transport) ?pardes.acmefs.Req { - return t.vtable.retry(t.ctx); - } - - pub fn next(t: Transport) ?pardes.acmefs.Req { - return t.vtable.next(t.ctx); - } - - pub fn reply(t: Transport, r: *const pardes.acmefs.Reply, bytes: []const u8) void { - t.vtable.reply(t.ctx, r, bytes); - } -}; - -/// 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 the TRANSPORT -/// contract rather than a preference — stated here because it belongs to the -/// caller, and every implementor inherits it: -/// -/// - `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 whatever is waiting on -/// the descriptor. For the FUSE mount that is a poll thread, and the -/// 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(t: Transport, core: *pardes.Pardes) Drained { - var d: Drained = .{}; - while (t.retry()) |req| { - step(t, core, req); - d.count += 1; - } - while (d.count < max_batch) { - const req = t.next() orelse return d; - step(t, 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 the transport -/// 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(t: Transport, 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); - t.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); -} |
