|
| 1 | +# Hot swap — upgrade without losing a single tab |
| 2 | + |
| 3 | +`tab-atelier upgrade` (or `POST /upgrade`) replaces the **running** |
| 4 | +binary with the one currently installed at its path while every tab's |
| 5 | +shell — and whatever is running inside it (a `claude` session, a build, |
| 6 | +an ssh connection) — keeps running, unaware anything happened. |
| 7 | + |
| 8 | +## Using it |
| 9 | + |
| 10 | +```sh |
| 11 | +# 1. Install the new version over the old one (any of these): |
| 12 | +sudo apt install ./tab-atelier_0.6.0_amd64.deb |
| 13 | +sudo cp target/release/tab-atelier /usr/bin/tab-atelier |
| 14 | + |
| 15 | +# 2. Ask the running instance to swap itself: |
| 16 | +tab-atelier upgrade # desktop GUI |
| 17 | +tab-atelier-headless upgrade # headless daemon |
| 18 | +# or: curl -X POST http://127.0.0.1:7890/upgrade \ |
| 19 | +# -H "Authorization: Bearer $(tab-atelier token)" |
| 20 | +``` |
| 21 | + |
| 22 | +The process re-execs within a couple of seconds (its next owner-loop |
| 23 | +tick). The GUI window closes and reopens on the new version; the |
| 24 | +headless daemon's API drops for a moment and re-binds. Tabs, shells, |
| 25 | +agents, cgroups, and nftables egress rules all survive. |
| 26 | + |
| 27 | +## How it works |
| 28 | + |
| 29 | +A normal restart forks fresh shells and replays saved output text. The |
| 30 | +hot swap instead `exec()`s the new binary **in place** (`src/hotswap.rs`): |
| 31 | + |
| 32 | +1. **Freeze.** `PtyTap::read` starts reporting `WouldBlock`, parking |
| 33 | + every PTY reader. Bytes the shells emit from now on wait in the |
| 34 | + kernel PTY buffers and are read by the new binary — nothing is lost. |
| 35 | +2. **Flush.** The usual quit-path persistence runs (tabs.json, per-tab |
| 36 | + output/uptime/energy), so the new binary restores names, cwds, grid |
| 37 | + contents, and scrollback through the existing restore code. |
| 38 | +3. **Handoff manifest.** For each live tab, a dup of the PTY **master** |
| 39 | + fd gets its `CLOEXEC` flag cleared, the raw `PtyRing` bytes are |
| 40 | + written to a sidecar (so web-viewer scrollback survives), and |
| 41 | + `(tab id, fd number, shell pid)` is recorded in |
| 42 | + `<state>/tab-atelier/handoff.json`. |
| 43 | +4. **exec.** The process replaces itself with the binary at its own |
| 44 | + path (`/proc/self/exe`, with dpkg's ` (deleted)` suffix stripped), |
| 45 | + passing `--handoff <manifest>` on argv. Because `exec` keeps the |
| 46 | + pid, the tab shells remain our **children** — process groups, |
| 47 | + controlling TTYs, and SIGCHLD reaping are all untouched. If the exec |
| 48 | + fails, everything rolls back and the old binary keeps running. |
| 49 | +5. **Adopt.** At boot the new binary validates the manifest (schema |
| 50 | + version + writer pid must equal its own pid — after exec they match; |
| 51 | + a stale manifest from a crashed swap never can) and stashes the fds |
| 52 | + in a registry keyed by tab id. The tab restore path claims entries |
| 53 | + from that registry and wraps each fd in an `AdoptedPty` — a drop-in |
| 54 | + for alacritty's Unix `Pty` (same poller tokens, SIGCHLD pipe, and |
| 55 | + `waitpid`-based exit detection) — instead of forking a shell. |
| 56 | + Unclaimed fds are closed once every tab has spawned. |
| 57 | + |
| 58 | +## What deliberately does NOT happen for adopted tabs |
| 59 | + |
| 60 | +- **No agent auto-resume.** The agent is still running in the adopted |
| 61 | + shell; typing `claude --resume …` would double-launch the session. |
| 62 | +- **No net-off respawn (GUI).** The adopted shell is still inside the |
| 63 | + bubblewrap netns the previous run put it in. |
| 64 | +- **No nftables teardown/re-apply (headless).** The tab's table and |
| 65 | + cgroup are kernel state that survived the exec; re-applying would |
| 66 | + open a brief unconfined window for the running shell. Only the |
| 67 | + daemon-side gating DNS resolver (a thread that died with the old |
| 68 | + process) is respawned for domain-allowlist tabs. |
| 69 | +- **No orphan reaping.** The swap deletes the agent reaper's provenance |
| 70 | + record (clean-handover semantics) and the headless cgroup reaper |
| 71 | + skips adopted tabs — both would otherwise SIGKILL exactly the |
| 72 | + processes the handoff kept alive. |
| 73 | + |
| 74 | +## Failure behaviour |
| 75 | + |
| 76 | +- Shell died mid-swap → its manifest entry fails the `waitpid` probe |
| 77 | + and the tab falls back to a normal fresh fork (with the carried ring |
| 78 | + bytes still seeding the scrollback above it). |
| 79 | +- `exec` failed (binary missing/corrupt) → `CLOEXEC` is restored, the |
| 80 | + manifest is removed, readers unfreeze, and the old binary keeps |
| 81 | + running; the error lands in the log and the endpoint caller's next |
| 82 | + poll. |
| 83 | +- Downgrading to a pre-hot-swap binary → the old binary ignores |
| 84 | + `--handoff`, so tabs respawn fresh (a normal restart) and the handed |
| 85 | + fds leak until the shells are HUP'd. Upgrade forward instead. |
| 86 | + |
| 87 | +## Limits |
| 88 | + |
| 89 | +- Unix only (Windows ConPTY handles can't cross an exec; the endpoint |
| 90 | + answers 501 there). |
| 91 | +- WebSocket viewers and `remote attach` clients are disconnected by the |
| 92 | + exec and must reconnect — their scrollback survives via the carried |
| 93 | + ring bytes. |
| 94 | +- The single-instance lock is dropped and re-acquired across the exec |
| 95 | + (std opens it `CLOEXEC`); a different instance racing for it in that |
| 96 | + window loses the tabs to the "already running" check — in practice |
| 97 | + unobservable. |
0 commit comments