597d8a35
fix: a shell you exited is not brought back by a zoomed tile
a73x 2026-08-26 07:35
Commit message
.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(); |