summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/detached/server.zig2
-rw-r--r--src/fs_service.zig75
-rw-r--r--src/fuse.zig37
-rw-r--r--src/gui/gui.zig4
-rw-r--r--src/tty/tty.zig2
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 `<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, "");
}
}
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