a73x

597d8a35

fix: a shell you exited is not brought back by a zoomed tile

a73x   2026-08-26 07:35

Commit message
fix: a shell you exited is not brought back by a zoomed tile

The page owns its re-attach and did it on every `up`, gated only on the
replica being unusable. A zoomed tile attaches at its real size, and a
sized attach is attach-or-create — so typing `exit` in a zoomed tile got
a new shell on the hub's next re-dial, with nothing refused and no birth
asked for. Declining to re-attach an `exited` tile is wallview's ending
reached by a client that cannot end. Found in Chrome; no test in this
repo executes mux.js.

.gitignore
Old New
@@ -12,4 +12,8 @@ dist/
12 .release/ 12 .release/
13 13
14 # Pinned toolchain, vendored (380MB): deps/zig/zig is what the Makefile runs. 14 # Pinned toolchain, vendored (380MB): deps/zig/zig is what the Makefile runs.
15 deps/zig/ 15 # No trailing slash, and `deps/quic/quic` beside it: a worktree links both in
16 # rather than copying them, and a trailing slash matches a directory only —
17 # which is how the symlinks got swept into two commits.
18 deps/zig
19 deps/quic/quic
CLAUDE.md
Old New
@@ -97,8 +97,11 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
97 restart must not leave the user's own wall a grid of `[refused]`, and a 97 restart must not leave the user's own wall a grid of `[refused]`, and a
98 saved file must not spawn a shell on another host. The hub reaches the 98 saved file must not spawn a shell on another host. The hub reaches the
99 same rule from the other side — it cannot claim a size, so it births the 99 same rule from the other side — it cannot claim a size, so it births the
100 session on a side connection when an attach is refused before any grid, 100 session on a side connection when an attach is refused before any grid.
101 and a session the user ENDED stays ended. 101 "A session the user ENDED stays ended" needs BOTH halves: the hub's
102 `ended` latch, and `sendAttach` declining to attach an `exited` tile at
103 all. A zoomed tile's attach carries a size, and a sized attach creates —
104 so the page can resurrect a shell without the hub involved.
102 - **The layout sidecar is derived convenience, not authored intent.** 105 - **The layout sidecar is derived convenience, not authored intent.**
103 `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, hydrated 106 `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, hydrated
104 walls only (no-argv `mux wall` or post-fold; argv walls and `mux TARGET` 107 walls only (no-argv `mux wall` or post-fold; argv walls and `mux TARGET`
docs/decisions.md
Old New
@@ -6866,3 +6866,48 @@ That word is new. The page said `session full`, which was a guess at WHY, and
6866 the daemon never says why: a missing session and a full table refuse with the 6866 the daemon never says why: a missing session and a full table refuse with the
6867 same `exit_status 1`. Most of the time the guess was wrong. It says `refused` 6867 same `exit_status 1`. Most of the time the guess was wrong. It says `refused`
6868 now — the CLI's word, for the CLI's reason. 6868 now — the CLI's word, for the CLI's reason.
6869
6870 ## 2026-08-26 — a zoomed browser tile was resurrecting the shell you exited
6871
6872 Found in Chrome against a live rig, not by any test here. Type `exit` in a
6873 ZOOMED browser tile and the session comes back: a new shell, in the tile you
6874 just closed.
6875
6876 The chain has nothing to do with the hub's restore rule, which is what made it
6877 hard to see. The shell exits behind a live grid, so `exit_status` reads as a
6878 shell exiting and the badge goes `exited` — TERMINAL, and `setStatus` will not
6879 leave it. The daemon reaps the session and closes the hub's connection; the hub
6880 redials and sends `up`; mux.js's `up` arm re-attaches, because the browser owns
6881 its own re-attach and that call was gated only on `dead`/`replayDead`. A zoomed
6882 tile's attach carries `zoomCols()×zoomRows()` rather than 0×0 — the passivity
6883 contract exempts the zoom, deliberately, because a zoomed tile IS the client
6884 looking at the session. And `server.resolveSession` is attach-or-create for any
6885 attach with a usable size. So the page created it. The hub's `ended` latch never
6886 came into it: nothing was refused, and no birth was asked for.
6887
6888 Ruled: the page declines to re-attach a tile whose status is `exited`. That is
6889 `wallview`'s ending — its pump ends on an `exit_status` and leaves the tile dead
6890 — reached by a client that cannot end, because it still has a socket and a
6891 canvas. `exited` exactly, not the TERMINAL set: `refused` must keep re-attaching
6892 or the hub's restore heal has no second half (refused → birth → re-dial → `up` →
6893 attach), and `stuck` already refuses inside `sendAttach` via `replayDead`. The
6894 latch lives in `sendAttach` rather than at the `up` arm that found the bug,
6895 because there are three callers and the dangerous one is the least obvious:
6896 `replayFailed`'s retry is a bare `setTimeout` that stores no handle and nothing
6897 cancels, so a replay failure followed within its wait by the shell exiting lands
6898 a sized attach on a tile already badged `exited` — the same resurrection by
6899 another road. It sits beside the `dead` gate rather than beside `replayDead`, so
6900 the selection and scroll clears below it are skipped: a dead tile's last screen
6901 is still worth copying text off, and a hub redialing every few seconds would
6902 otherwise wipe the selection each time. The latch clears through `ws.onopen`'s
6903 `revive`, so a reload or a hub restart is a new epoch and may create again — a person who reloads the page is asking for the
6904 wall they saved, which is the restore rule doing its job.
6905
6906 This predates the browser restore work entirely: any zoomed tile whose shell
6907 exited has resurrected it since sized attaches existed. It survived because
6908 nothing in `test/` can see it. `wsclient` stands in for the browser on the wire
6909 but attaches `0 0` and never executes `mux.js`, so every rule that lives in the
6910 page — this one, the `up` re-attach, the badge vocabulary — is invisible to the
6911 e2e suite by construction. Faking a leg with `wsclient` would assert the
6912 fixture's behavior, not the page's. A real-browser pass is the gate for mux.js
6913 behavior, and this entry is the reason to keep paying for one.
web/mux.js
Old New
@@ -611,6 +611,31 @@ class Tile {
611 // A shut-down tile has no wall to talk for: replayFailed's retry may 611 // A shut-down tile has no wall to talk for: replayFailed's retry may
612 // already be scheduled, and would otherwise repaint a detached node. 612 // already be scheduled, and would otherwise repaint a detached node.
613 if (this.dead) return; 613 if (this.dead) return;
614 // A session the user ENDED stays ended — wallview's pump ends on an
615 // exit_status and leaves the tile dead, and this is that ruling on
616 // the page's side of the wire. Not decoration: a ZOOMED tile attaches
617 // at its real size below, and a sized attach CREATES (server.zig
618 // resolveSession is attach-or-create for any usable size), so without
619 // this typing `exit` in a zoomed tile spawns a fresh shell. Unzoomed
620 // tiles were always safe by the passivity contract; the zoom is what
621 // carries the size.
622 //
623 // Here rather than at the callers, because there are three and the
624 // dangerous one is the least obvious: replayFailed's retry is a bare
625 // setTimeout that nothing cancels, so a replay failure followed
626 // within its wait by the shell exiting lands a sized attach on a tile
627 // already badged exited. Beside `dead` rather than beside
628 // `replayDead` so the clears below are skipped: a dead tile's last
629 // screen is still worth selecting text off, and a hub that redials
630 // every few seconds would otherwise wipe the selection each time.
631 //
632 // Exactly 'exited', not the whole TERMINAL set. 'refused' MUST keep
633 // re-attaching — the hub's restore heal is refused → it births the
634 // session → re-dial → `up` → this attach — and 'stuck' is what
635 // `replayDead` below already answers. The latch is cleared by
636 // ws.onopen's revive: a browser-leg reconnect (reload, hub restart)
637 // is a new epoch and may recreate, which is the deliberate escape.
638 if (this.status === 'exited') return;
614 this.cancelScrollRequest(); 639 this.cancelScrollRequest();
615 this.clearSelection(); 640 this.clearSelection();
616 if (this.replayDead) return; 641 if (this.replayDead) return;
@@ -716,7 +741,8 @@ class Tile {
716 // The hub speaks about the DAEMON leg, which is not the whole of 741 // The hub speaks about the DAEMON leg, which is not the whole of
717 // this tile: setStatus withholds the badge while the tile holds a 742 // this tile: setStatus withholds the badge while the tile holds a
718 // terminal state of its own. The bookkeeping below runs regardless 743 // terminal state of its own. The bookkeeping below runs regardless
719 // — sendAttach has its own gate (replayDead) and keeps it. 744 // — sendAttach has its own gates (dead, exited, replayDead) and
745 // keeps them.
720 if (state !== 'up') { 746 if (state !== 'up') {
721 this.cancelScrollRequest(); 747 this.cancelScrollRequest();
722 this.clearSelection(); 748 this.clearSelection();