From 0a21ea831d6791b406eef70b3e95cfd319d8ed36 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 15:28:21 -0300 Subject: 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. --- src/detached/server.zig | 2 +- src/fs_service.zig | 75 +++++++++++++++++++++++++++++++++++++++++-------- src/fuse.zig | 37 ++++++++++++++++++++++++ src/gui/gui.zig | 4 +-- src/tty/tty.zig | 2 +- 5 files changed, 104 insertions(+), 16 deletions(-) diff --git a/src/detached/server.zig b/src/detached/server.zig index 4437cc22..9a8dc24f 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -952,7 +952,7 @@ pub const Session = struct { // in the surface this frame composes, not the next one. The flag is // read by `waitInput`, which must not sleep while the kernel still has // requests we have not acknowledged. - if (s.fs) |f| s.fs_pending = fs_service.drain(f, s.core).pending; + if (s.fs) |f| s.fs_pending = fs_service.drain(f.transport(), s.core).pending; for (&s.ptys, 0..) |*pt, pane| { if (pt.fd < 0) continue; var lbuf: [1024]u8 = undefined; 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 `/` 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, ""); } } diff --git a/src/fuse.zig b/src/fuse.zig index 311d887b..3a903e8b 100644 --- a/src/fuse.zig +++ b/src/fuse.zig @@ -58,6 +58,10 @@ const builtin = @import("builtin"); const libc = std.c; const linux = std.os.linux; const acmefs = @import("acmefs.zig"); +/// Only for `Transport`, the three-function shape this mount presents to the +/// host loop. No cycle: `fs_service` names no type from here any more, which +/// is the point of the seam. +const fs_service = @import("fs_service.zig"); /// Everything below the mount is Linux kernel ABI. Off Linux the module still /// compiles (it is imported by the shared native shell) and does nothing. @@ -1059,6 +1063,39 @@ pub const Fs = struct { gpa.destroy(fs); } + /// This mount as the three functions `fs_service` actually calls. The + /// adapter exists so that file needs no `@import("fuse.zig")` to drive a + /// filesystem: `retry`, `next` and `reply` were always its whole use of an + /// `Fs`, and naming them lets a second transport answer the same calls. + /// + /// The thunks are three lines each because a `*Fs` is not an `*anyopaque` + /// and a vtable cannot hold the typed function directly. That is the entire + /// cost of the seam. + pub fn transport(fs: *Fs) fs_service.Transport { + return .{ .ctx = fs, .vtable = &transport_vtable }; + } + + const transport_vtable: fs_service.Transport.VTable = .{ + .retry = transportRetry, + .next = transportNext, + .reply = transportReply, + }; + + fn transportRetry(ctx: *anyopaque) ?acmefs.Req { + const fs: *Fs = @ptrCast(@alignCast(ctx)); + return fs.retry(); + } + + fn transportNext(ctx: *anyopaque) ?acmefs.Req { + const fs: *Fs = @ptrCast(@alignCast(ctx)); + return fs.next(); + } + + fn transportReply(ctx: *anyopaque, r: *const acmefs.Reply, bytes: []const u8) void { + const fs: *Fs = @ptrCast(@alignCast(ctx)); + fs.reply(r, bytes); + } + // -- request pump ------------------------------------------------------- /// Parse the next pending kernel request, or null when the descriptor is diff --git a/src/gui/gui.zig b/src/gui/gui.zig index c42e390b..da48d3fb 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3735,7 +3735,7 @@ fn pollFrame(ctx: ?*anyopaque) void { // poll thread, so nothing else will wake us — re-arm the loop ourselves; // `Queue.push` is lossy for this variant, which is correct, because a queue // too full to take a wake is already holding one. - if (s.fs) |f| if (fs_service.drain(f, core).pending) s.queue.push(.fs_ready); + if (s.fs) |f| if (fs_service.drain(f.transport(), core).pending) s.queue.push(.fs_ready); const g = s.gui orelse return; // TaglineSize is pure renderer state: update the smaller face and its // visual band immediately, without changing the body metrics or grid. @@ -3838,7 +3838,7 @@ fn gridPollFrame(ctx: ?*anyopaque) void { // something IS an event, so the pass renders: that is what lets a snapshot // `wait` for text a script wrote through the mount. if (s.fs) |f| { - if (fs_service.drain(f, s.core).count != 0) s.saw_event = true; + if (fs_service.drain(f.transport(), s.core).count != 0) s.saw_event = true; } pollCwds(s.core, s.ptys); // The harness polls stdin at the same 16 ms cadence as native SDL, so a diff --git a/src/tty/tty.zig b/src/tty/tty.zig index d7de3ae2..5eb9a277 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1213,7 +1213,7 @@ const Shell = struct { // reload is (see reloadWatched): one batch per frame, not one frame per // request. First in the pass, so an edit a script just made through // `body` is in the surface this frame composes rather than the next. - if (s.fs) |f| if (fs_service.drain(f, s.core).pending) { + if (s.fs) |f| if (fs_service.drain(f.transport(), s.core).pending) { // The batch hit its cap with requests still pending, and no ack has // gone to the poll thread — so nothing else will wake us. Re-arm // the loop ourselves. tryPostEvent, not postEvent: this runs on the -- cgit v1.3