diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/detached/server.zig | 130 | ||||
| -rw-r--r-- | src/main.zig | 28 |
2 files changed, 124 insertions, 34 deletions
diff --git a/src/detached/server.zig b/src/detached/server.zig index 93c964b4..4437cc22 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -161,6 +161,20 @@ const host_io = @import("../host_io.zig"); /// there, `waitInput`'s poll set here. const file_watch = @import("../file_watch.zig"); +/// acme's control filesystem, which a detached session had no way to serve +/// until now: `push_fs_reply` was the one machine-local effect this host left +/// null, so a script could drive a tty or an SDL session and not a daemon — +/// the configuration whose whole promise is outliving the terminal. +/// +/// It costs less here than it does in the desktop shells. They need a thread +/// blocked on `poll()` to notice a request and wake their loop +/// (`fs_service.wake`); this process already owns a `poll(2)` over everything +/// else it waits on, so `/dev/fuse` is one more descriptor in that set and +/// there is no thread at all. `Source.fuse` is the arm; `pollFrame` is the +/// drain, at the same point in the frame that tty.zig drains. +const fuse = @import("../fuse.zig"); +const fs_service = @import("../fs_service.zig"); + /// Host-lifetime storage for the OSC 133 rc files a forked shell sources, held /// by `Session` because a Session is exactly one host's lifetime. const shell_bin = @import("../shell_bin.zig"); @@ -236,9 +250,9 @@ const read_chunk = 16 * 1024; const pty_chunk = 64 * 1024; /// Descriptors in the ONE poll this process runs: the listener, every frontend, -/// every pane's pty master, and the inotify descriptor behind every watch. 50 -/// on a full house, and one syscall covers all of them. -const poll_slots = 1 + max_clients + pardes.MAX_PANES + 1; +/// every pane's pty master, the inotify descriptor behind every watch, and +/// `/dev/fuse`. 51 on a full house, and one syscall covers all of them. +const poll_slots = 1 + max_clients + pardes.MAX_PANES + 2; /// How many times `reloadWatched` will honour `file_watch.reloadChanged`'s /// request for another pass within one round. See `reloadWatched`. @@ -380,6 +394,12 @@ const Source = union(enum) { client: u8, pty: u8, inotify, + /// The acme filesystem's descriptor. Its arm does nothing: being in the set + /// is the whole point, because a readable `/dev/fuse` must end the sleep so + /// that `pollFrame` — which runs after `pull_wait_input` returns — reaches + /// the drain. Answering it here instead would re-enter the core from inside + /// its own `pump`. + fuse, }; pub const Session = struct { @@ -424,6 +444,14 @@ pub const Session = struct { /// The one inotify descriptor behind every watch this session holds, and the /// last member of the poll set. Opened lazily — see `inotify`. inotify_fd: c_int = -1, + /// acme's control filesystem, or null when `--fs` was not asked for or the + /// mount failed. Owned here rather than by `run` so that `deinit` unmounts + /// on every path out, including the error ones. + fs: ?*fuse.Fs = null, + /// The last drain stopped at `max_batch` with requests still in the kernel. + /// Same role as `check_files`: nothing else will wake us, because no + /// acknowledgement has gone back, so the next round must not sleep. + fs_pending: bool = false, /// Which directory mark belongs to which pane, and the generation the core /// has already accepted from each. file_watch.zig owns the shape and the /// transaction; this host owns only the descriptor and the wake. @@ -456,6 +484,15 @@ pub const Session = struct { // ---- lifetime --------------------------------------------------------- pub fn deinit(s: *Session) void { + // First, and before the pane shells: a script blocked on `event` is + // holding a kernel request, and `Fs.deinit` answers everything still + // parked and aborts the connection before unmounting. Leaving it until + // after the harvest would leave that reader in uninterruptible sleep + // for as long as the harvest takes. + if (s.fs) |f| { + f.deinit(); + s.fs = null; + } // Tell everyone the session is over before the socket disappears, so a // frontend exits on a `quit` rather than on a read error whose meaning // it has to guess. Best effort by construction: these descriptors are @@ -553,21 +590,26 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } - /// Sixteen methods, and NOT the fullest host in the tree — that claim stood - /// here, was believed, and was copied into docs/detached.md before an audit - /// counted the others. The tty and SDL shells fill NINETEEN each (everything - /// but `pull_gpio_toggle` and `push_detach`) and macOS fourteen, so this host - /// is the only one that implements `push_detach` and otherwise the least - /// complete of the three desktop hosts. What is true is narrower and is the - /// point anyway: it performs every MACHINE-LOCAL effect there is, and the - /// five of host.zig's twenty-one it leaves null are null because there is - /// nothing here for them to do. Three of those five are real losses a person - /// can notice — no `pull_lsp` and no `pull_pipe`, because both want the - /// worker pool this deliberately single-threaded loop does not have, and no - /// `push_fs_reply`, because this process mounted no /dev/fuse. The other two - /// are not losses at all: `push_post_present` marks the moment a frame - /// reached a screen and this process has no screen, and `pull_gpio_toggle` - /// wants pads. + /// Seventeen methods, and NOT the fullest host in the tree — that claim + /// stood here, was believed, and was copied into docs/detached.md before an + /// audit counted the others. The tty and SDL shells fill NINETEEN each + /// (everything but `pull_gpio_toggle` and `push_detach`) and macOS fourteen, + /// so this host is the only one that implements `push_detach` and otherwise + /// the least complete of the three desktop hosts. What is true is narrower + /// and is the point anyway: it performs every MACHINE-LOCAL effect there is, + /// and the four of host.zig's twenty-one it leaves null are null because + /// there is nothing here for them to do. Two of those four are real losses a + /// person can notice — no `pull_lsp` and no `pull_pipe`, because both want + /// the worker pool this deliberately single-threaded loop does not have. The + /// other two are not losses at all: `push_post_present` marks the moment a + /// frame reached a screen and this process has no screen, and + /// `pull_gpio_toggle` wants pads. + /// + /// `push_fs_reply` was the third real loss until this commit. It was null + /// because the daemon mounted no /dev/fuse, and the consequence was that a + /// detached session — the configuration whose whole promise is outliving the + /// terminal — was the one configuration no script could drive. It mounts one + /// now; see `fs` and `pollFrame`. /// /// `push_detach` is the one entry here that is not an effect. See `detach`. const vtable: host_api.Host.VTable = .{ @@ -587,6 +629,7 @@ pub const Session = struct { .pull_read_clipboard = readClipboard, .push_open_link = openLink, .push_detach = detach, + .push_fs_reply = fsReply, }; // ---- routing ---------------------------------------------------------- @@ -647,7 +690,7 @@ pub const Session = struct { // still sized like the pane the core reflowed. s.core.screen_h, s.core.screen_w, - null, // no `--fs` mount in a daemon: nothing here answers /dev/fuse + s.fs, ); // A `forkpty` that failed left `master` holding a number this process // does not own. The shells get away with not checking because they hand @@ -880,6 +923,16 @@ pub const Session = struct { if (s.origins()) |c| s.send(c, .detach); } + /// The core's answer to one filesystem request, handed straight back to the + /// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside + /// `perform` and is borrowed only for this call, so a body read is a window + /// onto the pane's live text and copies nothing. `.again` needs no case: + /// `Fs.reply` reads the status and re-parks the request itself. + fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { + const s = of(ctx); + if (s.fs) |f| f.reply(reply, bytes); + } + // ---- pane shells ------------------------------------------------------ /// Each pane's live cwd, for the tags. One readlink of /proc per pane that @@ -894,6 +947,12 @@ pub const Session = struct { /// watching. The pids are here now, so it does. fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); + // acme's filesystem first in the pass, for the reason tty.zig gives at + // its own call site: an edit a script just made through `body` belongs + // 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; for (&s.ptys, 0..) |*pt, pane| { if (pt.fd < 0) continue; var lbuf: [1024]u8 = undefined; @@ -1228,15 +1287,27 @@ pub const Session = struct { src[n] = .inotify; n += 1; } + // ...and acme's filesystem, when there is one. Its arm in `dispatch` + // does nothing: this descriptor is here to END THE SLEEP, so that the + // `pollFrame` after `pull_wait_input` returns reaches the drain. The + // desktop shells buy the same wake with a thread; one poll slot is + // cheaper and cannot race the loop. + if (s.fs) |f| if (f.fd >= 0) { + fds[n] = .{ .fd = f.fd, .events = poll_in, .revents = 0 }; + src[n] = .fuse; + n += 1; + }; // A session with no listener, no clients, no shells and no watches has // no event source at all. Returning immediately would spin the outer // `while (!core.quit)` at full speed, so sleep the interval the core // offered and, when it offered none, a frame's worth. // // Nothing is owed on this path. `check_files` is only ever set by a - // watch, and a watch means the inotify descriptor is in the set; and + // watch, and a watch means the inotify descriptor is in the set; // `reconcile` posts a resize only when a client is ATTACHED, which means - // its socket is in the set — so `n == 0` implies `!regridded` too. + // its socket is in the set; and `fs_pending` is only ever set by a drain, + // which runs only when `fs` is live, which puts `/dev/fuse` in the set. + // So `n == 0` implies `!regridded` and `!fs_pending` too. if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms); // Zero is the core's word for "sleep until something happens" (see // pardes.zig `pump`: it passes a frame interval only while an animation @@ -1254,7 +1325,7 @@ pub const Session = struct { // during `perform`, outside this function) and a regrid this round has // already performed. Both are consumed before this function returns, so // the round must not sleep before reaching them. - if (s.check_files or regridded) timeout = 0; + if (s.check_files or regridded or s.fs_pending) timeout = 0; const ready = libc.poll(&fds, @intCast(n), timeout); // A timeout is an ordinary frame boundary and EINTR is a signal we do not // handle here. Neither skips anything below any more: what used to be an @@ -1329,6 +1400,9 @@ pub const Session = struct { } }, .inotify => if (pfd.revents & poll_in != 0) s.drainInotify(), + // Nothing. See `Source.fuse`: the wake IS the work, and the drain + // belongs to `pollFrame`, where re-entering the core is legal. + .fuse => {}, }; } @@ -1761,6 +1835,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void return error.NoSocket; } + // Before the host is installed and before the startup drain, so a script + // that races the daemon's launch finds a tree whose panes already exist. + // Null on every failure — no fuse3, no `user_allow_other`, a kernel without + // FUSE — and a failure must cost the operator their scripting, never their + // session. `fs_service.start` has already said so on pane 0's message row. + // + // NO `fs_service.wake`. That call exists to start a thread that blocks on + // `poll()` and pokes a loop the thread does not otherwise share; this + // process polls `/dev/fuse` itself, in the same syscall as everything else. + // See `Source.fuse`. + session.fs = fs_service.start(gpa, core); + const h = session.host(); core.host = h; while (core.nextEffect()) |effect| core.perform(effect); diff --git a/src/main.zig b/src/main.zig index b8cb6b2d..1104aeb8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -304,19 +304,23 @@ fn nativeMain(init: std.process.Init) !void { // reading. Refused rather than resolved by declaration order, which would // silently drop whichever flag lost. if (detach != null and attach != null) return error.BadArgs; - // `--fs` mounts the acme control filesystem, and only a LOCAL session has - // one: `fs_service.start` is called inside tty.zig's `localSession` and - // gui.zig's equivalent, both of which an `--attach` skips entirely, and - // `detached/server.zig` never reads `opts.fs` at all. So `--fs` with either - // of these was parsed, stored, and then served by nobody. + // `--fs` mounts the acme control filesystem, and it needs a CORE to serve. + // `--attach` has none — it is a terminal whose state lives in another + // process — so the flag there would be parsed, stored, and served by + // nobody. Refused rather than dropped, and it is the stronger case of the + // line above: a contradiction is at least visible, whereas a silently + // dropped mount is invisible until someone waits for a directory that will + // never appear. // - // Refused for the same reason as the line above, and it is the stronger - // case: a contradiction is at least visible, whereas a silently dropped - // mount is invisible until someone waits for a directory that will never - // appear. Serving it instead would mean mounting FUSE in the detached core, - // which is a feature rather than a fix — `push_fs_reply` is one of the five - // host methods that core deliberately leaves null (docs/detached.md). - if (opts.fs != null and (detach != null or attach != null)) return error.BadArgs; + // `--detach` used to be refused here too, and is not any more. The reason + // given was that `push_fs_reply` was one of the host methods the detached + // core deliberately left null; it no longer is. A daemon mounts its own + // /dev/fuse and polls it in the same `poll(2)` as its frontends and its + // pane shells, which costs it one descriptor and no thread — strictly less + // than the desktop shells pay. `--detach --fs` is now the configuration + // that most wants a control filesystem, because it is the one whose panes + // outlive every terminal that could otherwise have scripted them. + if (opts.fs != null and attach != null) return error.BadArgs; // `--detach` replaces the frontend rather than choosing among them: the // core runs here, with no terminal, and the frontends are elsewhere on a // socket (src/detached/). It is checked before `platform` because it is not |
