Skip to content

Commit 755f5f7

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 28ce60f commit 755f5f7

18 files changed

Lines changed: 1485 additions & 70 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Tab Atelier is a Guake-style drop-down terminal emulator for Linux (X11), built
1717
- `src/power.rs` — per-tab power/energy monitoring via wattaouille
1818
- `src/screenshot.rs` — X11 screenshot capture to BMP
1919
- `src/schedule.rs` — per-tab off-hours auto-lock (OSM `opening_hours` + IANA tz). See `docs/schedule.md`.
20+
- `src/hotswap.rs` — in-place binary upgrade keeping every tab's shell alive (exec + PTY-fd handoff). See `docs/hot-swap.md`.
2021
- `src/tracking.rs` — Wakatime integration
2122
- `src/platform/linux.rs` — Linux-specific platform code (XDG dirs, X11 hotkeys, process info)
2223

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
@@ -97,6 +97,7 @@ The `.deb` lays out the following under FHS-standard paths:
9797
- Tabs, working directories, and full terminal output persisted across restarts
9898
- Active tab selection restored on startup
9999
- **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
100+
- **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)
100101

101102
**Preferences**
102103
- Theme selection (Dark, Tomorrow Night Blue)
@@ -226,6 +227,7 @@ Selected routes:
226227
| `DELETE` | `/tabs/{idx}` | Close a tab |
227228
| `POST` | `/tabs/rotate-tokens` | Revoke all per-tab share tokens (share links 401) — master only |
228229
| `POST` | `/master-token/reset` | Hot-swap the master API token (old token 401s) — master only |
230+
| `POST` | `/upgrade` | Hot-swap onto the newly installed binary, tabs stay live ([docs](docs/hot-swap.md)) — master only |
229231

230232
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.
231233

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:
@@ -295,6 +297,33 @@ paths:
295297
schema: { type: object, properties: { token: { type: string } } }
296298
"401": { $ref: "#/components/responses/Unauthorized" }
297299
"500": { description: Could not persist token }
300+
/upgrade:
301+
post:
302+
tags: [admin]
303+
summary: Hot-swap onto the newly installed binary (tabs stay live)
304+
description: >
305+
Re-exec the running process onto the binary currently installed at
306+
its own path, handing every tab's live PTY across the exec — the
307+
shells, and whatever runs inside them, are never restarted. Install
308+
the new binary first (apt upgrade / copy over the file), then call
309+
this. The swap happens moments after the response flushes; expect
310+
the API to drop briefly while the new binary boots and re-binds.
311+
Unix only; master token only.
312+
security:
313+
- bearerAuth: []
314+
responses:
315+
"200":
316+
description: Swap armed
317+
content:
318+
application/json:
319+
schema:
320+
type: object
321+
properties:
322+
upgrading: { type: boolean }
323+
pid: { type: integer }
324+
"401": { $ref: "#/components/responses/Unauthorized" }
325+
"409": { description: No binary found at the re-exec path }
326+
"501": { description: Platform without exec-based handoff (Windows) }
298327
components:
299328
securitySchemes:
300329
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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,6 +2777,36 @@ fn handle_connection<S: Read + Write>(stream: &mut S, state: &Arc<Mutex<TabSnaps
27772777
drop(state);
27782778
respond_json(stream, 200, &format!(r#"{{"revoked":{revoked}}}"#));
27792779
}
2780+
("POST", "/upgrade") => {
2781+
// Hot-swap upgrade: re-exec the (freshly installed) binary at
2782+
// our own install path while every tab's live PTY is handed
2783+
// across the exec — the shells, and whatever runs in them,
2784+
// never notice (see src/hotswap.rs). Master token only (not
2785+
// in the share-token allowlist); refused in read-only mode by
2786+
// the is_mutating gate above. The swap happens on the owner
2787+
// loop's next tick, after this response has flushed — expect
2788+
// the API to drop for a moment while the new binary boots and
2789+
// re-binds.
2790+
#[cfg(unix)]
2791+
{
2792+
if !crate::hotswap::reexec_target_ok() {
2793+
error_json(
2794+
stream,
2795+
409,
2796+
"re-exec target missing — install the new binary at this binary's path first",
2797+
);
2798+
return;
2799+
}
2800+
crate::hotswap::request_upgrade();
2801+
respond_json(
2802+
stream,
2803+
200,
2804+
&format!(r#"{{"upgrading":true,"pid":{}}}"#, std::process::id()),
2805+
);
2806+
}
2807+
#[cfg(not(unix))]
2808+
error_json(stream, 501, "hot swap is not supported on this platform");
2809+
}
27802810
("POST", "/master-token/reset") => {
27812811
// Hot-swap the master API token: generate a fresh one, persist
27822812
// it to api.token (so `tab-atelier token` and saved configs

src/app.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -769,17 +769,32 @@ impl AppState {
769769
// airgapped. Skipped (net left on) when bwrap isn't
770770
// installed, so a persisted net-off tab doesn't boot
771771
// into a dead shell on a host without bubblewrap.
772+
// A hot-swap-adopted shell is skipped too: it is still
773+
// inside the bubblewrap netns the previous run put it
774+
// in, and respawning would kill exactly the process the
775+
// handoff kept alive. (Net-off tabs never defer their
776+
// spawn, so `was_adopted` is already accurate here.)
772777
if ts.net_disabled && crate::bwrap_available() {
773778
view.update(cx, |v, _| {
774779
v.set_net_disabled(true);
775-
v.respawn(cwd.as_deref());
780+
if !v.was_adopted() {
781+
v.respawn(cwd.as_deref());
782+
}
776783
});
777784
}
778785
// Auto-resume: if this tab had an agent session and kind
779786
// persisted, queue the resume command to be typed into the
780787
// freshly-spawned shell — UNLESS we already launched the
781788
// agent directly above (then typing it would double-launch).
782-
let pending_agent_resume = if agent_launch.is_some() || crate::read_only() {
789+
// …and never into a hot-swap-adopted shell: its agent is
790+
// still running, so typing a `--resume` would double-
791+
// launch the session. `adoptable` covers deferred tabs
792+
// whose adoption happens later in the boot loader.
793+
let pending_agent_resume = if agent_launch.is_some()
794+
|| crate::read_only()
795+
|| crate::hotswap::adoptable(&ts.id)
796+
|| view.read(cx).was_adopted()
797+
{
783798
None
784799
} else {
785800
match (&ts.agent_kind, &ts.agent_session_id) {
@@ -995,6 +1010,12 @@ impl AppState {
9951010
})
9961011
.unwrap_or(true);
9971012
if done {
1013+
// Every tab has spawned (and claimed its hot-swap
1014+
// handoff, if any). A handoff fd still unclaimed
1015+
// belongs to a tab that no longer exists — close it
1016+
// so its orphaned shell gets its HUP instead of
1017+
// wedging on a full PTY buffer nobody drains.
1018+
crate::hotswap::close_unclaimed();
9981019
break;
9991020
}
10001021
}
@@ -1984,6 +2005,16 @@ impl AppState {
19842005
return;
19852006
}
19862007

2008+
// A hot-swap upgrade came in (`POST /upgrade`): flush all state,
2009+
// then replace this process with the (re)installed binary at our
2010+
// own path, handing every tab's live PTY across the exec — the
2011+
// shells never notice. Returns only if the exec failed.
2012+
#[cfg(unix)]
2013+
if crate::hotswap::upgrade_requested() && !crate::read_only() {
2014+
self.hot_swap(cx);
2015+
return;
2016+
}
2017+
19872018
// Skipped entirely while nobody consumes the API — the previous
19882019
// snapshot stays in place (never wiped with an empty one) and
19892020
// the first request after idle serves it, at most 2 s + idle
@@ -2430,7 +2461,11 @@ impl AppState {
24302461
self.tabs[idx].name = new_name;
24312462
}
24322463

2433-
fn close_all_tabs(&mut self, cx: &mut Context<Self>) {
2464+
/// Unconditional flush of tabs.json + every tab's output / uptime /
2465+
/// energy files — the "this process is about to go away" save.
2466+
/// Shared by `close_all_tabs` (quit) and `hot_swap` (exec into the
2467+
/// next binary).
2468+
fn flush_all_state(&mut self, cx: &mut Context<Self>) {
24342469
let state_base = platform::state_base_dir();
24352470
// Snapshot cwd from /proc one last time before child processes
24362471
// disappear; fall back to the cached last_known_cwd otherwise.
@@ -2492,6 +2527,47 @@ impl AppState {
24922527
tab.energy_wh_last_saved = tab.energy_wh;
24932528
}
24942529
}
2530+
}
2531+
2532+
/// Replace this process with the binary at our own install path,
2533+
/// handing every live tab's PTY across the exec (see
2534+
/// [`crate::hotswap`]). The window closes and reopens; the shells —
2535+
/// and whatever is running in them — never notice. Returns only if
2536+
/// the exec failed, in which case we keep running as before.
2537+
#[cfg(unix)]
2538+
fn hot_swap(&mut self, cx: &mut Context<Self>) {
2539+
crate::hotswap::clear_upgrade_request();
2540+
self.flush_all_state(cx);
2541+
let mut sources = Vec::new();
2542+
for tab in &self.tabs {
2543+
let view = tab.view.read(cx);
2544+
// Skeletons (no shell yet) and exited shells carry no fd —
2545+
// they restore from tabs.json exactly like today.
2546+
if view.has_exited() {
2547+
continue;
2548+
}
2549+
let Some(master) = view.handoff_master() else {
2550+
continue;
2551+
};
2552+
let ring_arc = view.pty_ring();
2553+
let ring = ring_arc
2554+
.lock()
2555+
.unwrap_or_else(std::sync::PoisonError::into_inner)
2556+
.since(0);
2557+
sources.push(crate::hotswap::HandoffSource {
2558+
id: tab.id.clone(),
2559+
master,
2560+
pid: view.pid(),
2561+
ring,
2562+
});
2563+
}
2564+
log::info!("hot swap: handing off {} live tab(s)", sources.len());
2565+
let err = crate::hotswap::exec_swap(&sources);
2566+
log::error!("hot swap failed, continuing on the old binary: {err}");
2567+
}
2568+
2569+
fn close_all_tabs(&mut self, cx: &mut Context<Self>) {
2570+
self.flush_all_state(cx);
24952571

24962572
if let Some(ref tracker) = self.tracker {
24972573
tracker.shutdown();
@@ -5339,6 +5415,16 @@ pub fn run() {
53395415

53405416
info!("starting Tab Atelier v{}", env!("CARGO_PKG_VERSION"));
53415417

5418+
// Hot-swap handoff: when the previous binary exec'd into us it left
5419+
// `--handoff <manifest>` on argv, naming the live PTY fds it kept
5420+
// open across the exec. Adopt them BEFORE the reaper and the tab
5421+
// restore below, so restored tabs reattach to their running shells
5422+
// instead of forking fresh ones.
5423+
let adopted = crate::hotswap::adopt_from_args();
5424+
if adopted > 0 {
5425+
info!("hot swap: inherited {adopted} live tab(s) from the previous binary");
5426+
}
5427+
53425428
// Reap agent processes leaked by a prior (unclean) run before we
53435429
// restore any tab — reclaims the stopped `claude` ghosts that
53445430
// reparented to init. Provenance-based (only kills processes this GUI

0 commit comments

Comments
 (0)