summaryrefslogtreecommitdiff
path: root/src/fs_service.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/fs_service.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/fs_service.zig')
-rw-r--r--src/fs_service.zig324
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);
-}