summaryrefslogtreecommitdiff
path: root/src/fs_service.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-27 15:28:21 -0300
committerGabriel Schneider <[email protected]>2026-08-27 15:32:02 -0300
commit0a21ea831d6791b406eef70b3e95cfd319d8ed36 (patch)
treecd92d6c745ac6aa19e8933f5bee17db6854b6dab /src/fs_service.zig
parente9897bd9566832dfa09f4d3cf5daa6f3d0a84bd5 (diff)
downloadpardes-0a21ea831d6791b406eef70b3e95cfd319d8ed36.tar.gz
pardes-0a21ea831d6791b406eef70b3e95cfd319d8ed36.zip
fs_service: the transport is a ctx and three functions, not a *fuse.Fs
Step 2 of the 9P chain (docs/9p.typ 12.2, docs/registry.typ 9P-2). `drain` and `step` never asked a `*fuse.Fs` for anything but `retry()`, `next()` and `reply()`, so the concrete pointer was a coupling that bought nothing and forbade a second answer. `Transport` names the three; `Fs.transport()` is the first implementor and the thunks are the entire cost. No behaviour change. The order contract -- retry() to null, then next() to null -- moves into `drain`'s doc comment, where it belongs: it is the caller's rule and every implementor inherits it, rather than a fact about FUSE. `start` and `wake` keep their `*fuse.Fs`: they are about a MOUNT, which is a FUSE thing, and a 9P listener will bring its own. Measured unchanged against zig build fs-bench -Doptimize=ReleaseFast: getattr 19 ns, lookup 40, read body 4K/1M 25/25, read ctl 385, read index 633, readdir 38, read event (empty) 22, all at zero allocations.
Diffstat (limited to 'src/fs_service.zig')
-rw-r--r--src/fs_service.zig75
1 files changed, 63 insertions, 12 deletions
diff --git a/src/fs_service.zig b/src/fs_service.zig
index 6fdb9991..7821e850 100644
--- a/src/fs_service.zig
+++ b/src/fs_service.zig
@@ -34,6 +34,55 @@ extern "c" fn unsetenv(name: [*:0]const u8) c_int;
/// 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`: the two callers
+/// (tty.zig and gui.zig) store the transport in a struct field across frames,
+/// 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`.
+///
+/// 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.
///
@@ -144,26 +193,28 @@ pub const Drained = struct {
/// 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:
+/// 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 the poll thread. That
-/// handshake is what stops a level-triggered `poll()` from spinning a core,
+/// - `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(fs: *fuse.Fs, core: *pardes.Pardes) Drained {
+pub fn drain(t: Transport, core: *pardes.Pardes) Drained {
var d: Drained = .{};
- while (fs.retry()) |req| {
- step(fs, core, req);
+ while (t.retry()) |req| {
+ step(t, core, req);
d.count += 1;
}
while (d.count < max_batch) {
- const req = fs.next() orelse return d;
- step(fs, core, req);
+ const req = t.next() orelse return d;
+ step(t, core, req);
d.count += 1;
}
d.pending = true;
@@ -173,7 +224,7 @@ pub fn drain(fs: *fuse.Fs, core: *pardes.Pardes) Drained {
/// 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`
+/// 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.
///
@@ -185,7 +236,7 @@ pub fn drain(fs: *fuse.Fs, core: *pardes.Pardes) Drained {
/// (`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 {
+fn step(t: Transport, core: *pardes.Pardes, req: pardes.acmefs.Req) void {
core.update(.{ .fs_req = req });
var answered = false;
while (core.nextEffect()) |e| {
@@ -194,7 +245,7 @@ fn step(fs: *fuse.Fs, core: *pardes.Pardes, req: pardes.acmefs.Req) void {
}
if (!answered) {
const eio = pardes.acmefs.Reply.fail(req.tag, pardes.acmefs.E.IO);
- fs.reply(&eio, "");
+ t.reply(&eio, "");
}
}