diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-27 15:28:21 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 15:32:02 -0300 |
| commit | 0a21ea831d6791b406eef70b3e95cfd319d8ed36 (patch) | |
| tree | cd92d6c745ac6aa19e8933f5bee17db6854b6dab /src/fs_service.zig | |
| parent | e9897bd9566832dfa09f4d3cf5daa6f3d0a84bd5 (diff) | |
| download | pardes-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.zig | 75 |
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, ""); } } |
