From f5927a033f0c83753b5cc004e514568eec8c24f8 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 15:32:02 -0300 Subject: detached: drop /dev/fuse from the poll set once the connection dies Review fixes to steps 1 and 2, found by an adversarial pass over the committed chain. One is a real bug and the rest are comments that were false. THE BUG. `waitInput` put the FUSE descriptor in the poll set whenever the mount existed, and the `.fuse` arm ignored every revent. Linux's `fuse_dev_poll` answers EPOLLERR once the connection is gone, and POSIX reports POLLERR whatever the events mask asked for -- so an external `fusermount3 -u`, a sysfs abort, or systemd taking /run/user/$UID away at final logout (exactly when a detached session is supposed to keep running) made poll(2) return instantly, forever. The daemon then spun the whole pump at 100% of a core for the rest of its life, and re-offered every parked slot to the core at that rate. Measured on the unfixed commit: 0 CPU ticks over 10 s idle, then 1000 ticks over the next 10 s after unmounting its own mount point. Measured after the fix: 0 ticks over 8 s in the same scenario, process alive and in state S. fuse.zig's own poll thread has carried the equivalent guard all along, which is why the desktop shells never showed this and the daemon did. THE COMMENTS, each checkable and each wrong: * `Source.fuse` said the drain is not done in `dispatch` to avoid re-entering the core. Both `pull_wait_input` and `push_poll_frame` are called from inside `pump`, so either re-enters. The real reason is that `dispatch` is mid-iteration over a SNAPSHOT of the descriptors, and one `push_spawn` replaces a pane's master under it. * `fuse.zig`'s new import said "no cycle". There is a cycle: fs_service imports fuse.zig back and still names `*Fs` in three signatures. * `Transport`'s rationale said tty.zig and gui.zig store one in a struct field. Neither does; all four call sites build it inline. * `deinit` justified its ordering against "as long as the harvest takes". `harvest` is waitpid(WNOHANG) and blocks for nothing. The real reason to go first is that aborting the connection wakes a parked reader while its own shell is still alive to run its exit path. * `harvest` claimed pane shells are the only children this process forks. Since step 1 it also forks `fusermount3`, three times over -- reaped by its own spawner, so the conclusion holds and the premise did not. * docs/detached.md, docs/lsp.md and docs/design.typ still said the daemon implements sixteen of twenty-one methods and mounts no /dev/fuse. --- src/detached/server.zig | 73 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 17 deletions(-) (limited to 'src/detached') diff --git a/src/detached/server.zig b/src/detached/server.zig index d8373149..8e6b92d4 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -394,11 +394,22 @@ 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`. + /// The acme filesystem's descriptor. Its arm does one thing only: notice + /// that the connection has gone. Being in the set is otherwise 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. + /// + /// The drain is not done HERE, and the reason is not re-entrancy: both + /// `pull_wait_input` and `push_poll_frame` are called from inside + /// `Pardes.pump`, so either would re-enter. It is that `dispatch` is + /// mid-iteration over the `fds[0..n]`/`src[0..n]` SNAPSHOT `waitInput` + /// built, and a filesystem request reaches `core.perform`, which drains the + /// whole effect ring — including a `push_spawn`, whose `Session.spawn` + /// closes a pane's master and forks a new one. A later `.pty` entry in the + /// same pass would then apply the old descriptor's `revents` to a brand new + /// one, and the `fd < 0` guard cannot see it because the fd is valid, + /// merely different. Same hazard as the client slots, same reason. fuse, }; @@ -486,9 +497,18 @@ pub const Session = struct { 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. + // parked and ABORTS THE CONNECTION before unmounting, so that reader + // wakes with ENODEV while its own shell is still alive to run its exit + // path. After `closePty` it would be woken by a hangup instead, with + // nothing left to exit into. + // + // Not, as this comment first claimed, because the harvest takes time: + // `harvest` is `waitpid(WNOHANG)` in a loop and blocks for nothing. The + // one step here that CAN take milliseconds is this one, because + // `Fs.deinit` forks `fusermount3` and waits for it untimed — which is + // also why the `.quit` owed to every frontend now queues behind a + // subprocess. Worth knowing; not worth reordering, because the shells + // matter more than the milliseconds. if (s.fs) |f| { f.deinit(); s.fs = null; @@ -1083,11 +1103,16 @@ pub const Session = struct { /// Collect every child that has exited. /// /// `waitpid(-1)` and not a pid list, because the only children this process - /// forks are pane shells (`host_io.forkShell`) — so "any exited child" and - /// "an exited pane shell" are the same set — and because the pids a list - /// would hold are exactly the ones it cannot help with: a respawn closes a - /// master, and the shell that gets the hangup exits some milliseconds later - /// with its slot already reused by a different shell. + /// LEAVES UNREAPED are pane shells (`host_io.forkShell`) — so "any exited + /// child" and "an exited pane shell" are the same set — and because the pids + /// a list would hold are exactly the ones it cannot help with: a respawn + /// closes a master, and the shell that gets the hangup exits some + /// milliseconds later with its slot already reused by a different shell. + /// Anything else this process forks — `fusermount3`, from `Fs.mount`, + /// `sweepStale` and `Fs.deinit` — is reaped by its own spawner with a + /// pid-specific blocking wait before control returns here, so the set this + /// sees is still only shells. A future worker that forks and does not wait + /// would break that, and this is the sentence it has to come back and edit. /// /// Nothing here waits, so a session whose shells are all running pays one /// syscall that returns 0. Called once per poll round and again wherever a @@ -1305,7 +1330,17 @@ pub const Session = struct { // `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) { + // ...and NOT once it is dead. `fuse_dev_poll` answers `EPOLLERR` as soon + // as the connection is gone, POSIX reports `POLLERR` whatever the events + // mask asked for, and this arm cannot consume it — so an external + // `fusermount3 -u`, a sysfs abort, or systemd taking `/run/user/$UID` + // away at final logout (exactly when a detached session is supposed to + // keep running) would make `poll(2)` return instantly, forever, and burn + // a whole core for the life of the daemon. Measured at 100% of one CPU + // before this guard. fuse.zig's own poll thread has carried the + // equivalent check all along, which is why the desktop shells never + // showed it and this loop did. + if (s.fs) |f| if (f.fd >= 0 and !f.dead) { fds[n] = .{ .fd = f.fd, .events = poll_in, .revents = 0 }; src[n] = .fuse; n += 1; @@ -1413,9 +1448,13 @@ 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 => {}, + // The only revents worth a word: a dead connection must leave the + // set, or the `POLLERR` it reports on every future poll spins the + // loop. `Fs.next` would set `dead` on its first failed read anyway; + // saying it here costs nothing and saves the one spinning round. + .fuse => if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { + if (s.fs) |f| f.dead = true; + }, }; } -- cgit v1.3