Skip to content

Commit 145b078

Browse files
committed
feat: hot-swap upgrade — replace the binary while every tab stays live
Add src/hotswap.rs: on 'tab-atelier upgrade' / POST /upgrade the running process re-execs the binary installed at its own path, handing each tab's PTY master fd (CLOEXEC cleared) plus a pid-validated JSON manifest across the exec. Because exec keeps the pid, tab shells remain our children — process groups, controlling TTYs, cgroups, and nftables rules are untouched, so agents and builds inside the tabs never notice. The new image adopts the fds at boot (AdoptedPty mirrors alacritty's Unix Pty: same poller tokens, SIGCHLD pipe, waitpid exit detection) instead of forking shells; grid contents restore through the existing saved-output replay and the carried raw ring bytes re-seed viewer scrollback. PTY readers freeze during the handoff so unread bytes wait in the kernel and are parsed by the new binary — nothing is lost. Adopted tabs skip exactly the work that assumes a fresh shell: agent auto-resume (would double-launch the still-running claude), the GUI net-off bubblewrap respawn (still jailed), the headless nftables teardown/re-apply (would blip enforcement; only the gating DNS resolver is respawned), the cgroup stale reap, and the agent reaper's provenance record (removed at swap so the new boot can't SIGKILL the inherited fleet). A shell that dies mid-swap falls back to a normal fresh fork; a failed exec rolls back fully and the old binary keeps running. Trigger surface: POST /upgrade (master token, 409 when no binary at the re-exec path, 501 on Windows), 'upgrade' subcommand on both binaries, docs/hot-swap.md, openapi.yaml entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTkVxFVGsaywyG4iJyTyvc
1 parent 8128a04 commit 145b078

19 files changed

Lines changed: 1514 additions & 85 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Tab Atelier is a Guake-style drop-down terminal emulator for Linux (X11), built
1919
- `src/screenshot.rs` — X11 screenshot capture to BMP
2020
- `src/cli/style.rs` — per-project (folder-keyed) tab colour + badge. See `docs/tab-style.md`.
2121
- `src/schedule.rs` — per-tab off-hours auto-lock (OSM `opening_hours` + IANA tz). See `docs/schedule.md`.
22+
- `src/hotswap.rs` — in-place binary upgrade keeping every tab's shell alive (exec + PTY-fd handoff). See `docs/hot-swap.md`.
2223
- `src/tracking.rs` — Wakatime integration
2324
- `src/platform/linux.rs` — Linux-specific platform code (XDG dirs, X11 hotkeys, process info)
2425

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ tempfile = "3"
222222
# platform::random_bytes() has a real CSPRNG. Already present
223223
# transitively (rand, uuid) — gated to Windows so the Linux dep graph
224224
# is untouched.
225+
# Hot-swap upgrade (src/hotswap.rs): safe wrappers for the fd handoff —
226+
# fcntl CLOEXEC twiddling, waitpid/kill on the adopted PTY children,
227+
# TIOCSWINSZ on the inherited masters. Both crates are already in the
228+
# tree transitively (alacritty_terminal / polling), so these direct
229+
# deps add no new compilation units.
230+
[target.'cfg(unix)'.dependencies]
231+
rustix = { version = "1", features = ["fs", "process", "termios"] }
232+
signal-hook = "0.3"
233+
225234
# gpui with the X11 backend — Linux desktop build only.
226235
[target.'cfg(target_os = "linux")'.dependencies]
227236
gpui = { version = "0.2", default-features = false, features = ["x11"], optional = true }

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Always pass `-p tab-atelier` — a bare `cargo deb` in this workspace can packag
102102
- Tabs, working directories, and full terminal output persisted across restarts
103103
- Active tab selection restored on startup
104104
- **Agent auto-resume**: tabs that were running `catbus-agent` or `claude` at last save reopen with `catbus-agent --resume <uuid>` / `claude --resume <uuid>` typed into the freshly-spawned shell
105+
- **Hot-swap upgrade**: `tab-atelier upgrade` re-execs the newly installed binary in place, handing every tab's live PTY across — shells (and the agents running in them) are never restarted. See [docs/hot-swap.md](docs/hot-swap.md)
105106

106107
**Preferences**
107108
- Theme selection (Dark, Tomorrow Night Blue, Light)
@@ -235,6 +236,7 @@ Selected routes:
235236
| `DELETE` | `/tabs/{idx}` | Close a tab |
236237
| `POST` | `/tabs/rotate-tokens` | Revoke all per-tab share tokens (share links 401) — master only |
237238
| `POST` | `/master-token/reset` | Hot-swap the master API token (old token 401s) — master only |
239+
| `POST` | `/upgrade` | Hot-swap onto the newly installed binary, tabs stay live ([docs](docs/hot-swap.md)) — master only |
238240

239241
Bind addresses for both listeners are configurable in preferences (`api_addr`, `api_tls_addr`); pass `--read-only` to launch a second instance that serves the API but refuses every mutating verb.
240242

assets/openapi.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ tags:
2525
description: Tab list, I/O and per-tab state
2626
- name: tokens
2727
description: Master token + share-link token management
28+
- name: admin
29+
description: Process-level administration (hot-swap upgrade)
2830
paths:
2931
/tabs:
3032
get:
@@ -330,6 +332,33 @@ paths:
330332
schema: { type: object, properties: { token: { type: string } } }
331333
"401": { $ref: "#/components/responses/Unauthorized" }
332334
"500": { description: Could not persist token }
335+
/upgrade:
336+
post:
337+
tags: [admin]
338+
summary: Hot-swap onto the newly installed binary (tabs stay live)
339+
description: >
340+
Re-exec the running process onto the binary currently installed at
341+
its own path, handing every tab's live PTY across the exec — the
342+
shells, and whatever runs inside them, are never restarted. Install
343+
the new binary first (apt upgrade / copy over the file), then call
344+
this. The swap happens moments after the response flushes; expect
345+
the API to drop briefly while the new binary boots and re-binds.
346+
Unix only; master token only.
347+
security:
348+
- bearerAuth: []
349+
responses:
350+
"200":
351+
description: Swap armed
352+
content:
353+
application/json:
354+
schema:
355+
type: object
356+
properties:
357+
upgrading: { type: boolean }
358+
pid: { type: integer }
359+
"401": { $ref: "#/components/responses/Unauthorized" }
360+
"409": { description: No binary found at the re-exec path }
361+
"501": { description: Platform without exec-based handoff (Windows) }
333362
components:
334363
securitySchemes:
335364
bearerAuth:

docs/hot-swap.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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.

src/api.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ mod status;
3232
mod tab_props;
3333
mod tabs;
3434
mod tokens;
35+
mod upgrade;
3536
mod usage;
3637
mod view;
3738

@@ -1829,6 +1830,7 @@ fn handle_connection<S: Read + Write>(stream: &mut S, state: &Arc<Mutex<TabSnaps
18291830
("POST", p) if p.starts_with("/tabs/by-id/") && p.ends_with("/schedule") => {
18301831
schedule::run(stream, state, p, &body_bytes);
18311832
}
1833+
("POST", "/upgrade") => upgrade::run(stream),
18321834
("POST", "/tabs/rotate-tokens") => tokens::rotate(stream, state),
18331835
("POST", "/master-token/reset") => tokens::reset_master(stream, state),
18341836
("POST", p) if p.starts_with("/tabs/by-id/") && p.ends_with("/bg-color") => {

src/api/upgrade.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! Hot-swap upgrade trigger: re-exec the (freshly installed) binary at our
6+
//! own install path while every tab's live PTY is handed across the exec.
7+
//! Master token only.
8+
9+
use std::io::Write;
10+
11+
#[cfg(not(unix))]
12+
use super::error_json;
13+
#[cfg(unix)]
14+
use super::{error_json, respond_json};
15+
16+
pub(super) fn run<W: Write>(stream: &mut W) {
17+
// The shells, and whatever runs in them, never notice the swap (see
18+
// src/hotswap.rs). Not in the share-token allowlist; refused in
19+
// read-only mode by the dispatcher's is_mutating gate. The swap happens
20+
// on the owner loop's next tick, after this response has flushed —
21+
// expect the API to drop for a moment while the new binary boots and
22+
// re-binds.
23+
#[cfg(unix)]
24+
{
25+
if !crate::hotswap::reexec_target_ok() {
26+
error_json(
27+
stream,
28+
409,
29+
"re-exec target missing — install the new binary at this binary's path first",
30+
);
31+
return;
32+
}
33+
crate::hotswap::request_upgrade();
34+
respond_json(
35+
stream,
36+
200,
37+
&format!(r#"{{"upgrading":true,"pid":{}}}"#, std::process::id()),
38+
);
39+
}
40+
#[cfg(not(unix))]
41+
error_json(stream, 501, "hot swap is not supported on this platform");
42+
}

0 commit comments

Comments
 (0)