Hot-reload onto a rebuilt binary with ctrl-r

Restarting to pick up a code change costs the three things this app
exists to hold: the proxy port, the embedded claude pane, and the live
feed. So ctrl-r does not restart — it execs the binary now on disk into
this same process. execve keeps the pid, the open fds and the child
processes, so the listener socket, the pty and its claude child all
simply carry on; the feed travels in a JSON snapshot.

Build nothing, watch nothing. A build is the user's business, and a
running instance must not decide on its own when to become different
code. Rebuild outside, then press ctrl-r in each instance.

Drain before the exec. It destroys the tokio tasks relaying in-flight
responses, so the proxy stops accepting and finishes what it has first.
The socket stays open throughout (App::listener_fd is a dup), so
requests made during the swap queue in the kernel backlog and are served
by the new image — verified end to end: nothing refused, nothing cut.

Treat the snapshot as advisory. It is written by the old binary and read
by the new one, whose types usually just changed — that is the normal
case, not the edge case. The fd numbers stay in plain fields and the
feed is decoded per session behind a sanitiser, so a schema change costs
the feed and never the port or the pane.
This commit is contained in:
Jonas H
2026-08-27 11:46:15 +02:00
parent 8907766b5b
commit 1f483ad3d1
10 changed files with 1308 additions and 31 deletions

View File

@@ -9,7 +9,11 @@ extra usage is the core constraint of this project.
## Architecture
```
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode.
Also the *incoming* half of a hot reload: `reload::take_handoff`
decides whether this process was started by a user or exec'd by
its own previous image, and `open_listener` adopts the inherited
accept socket instead of binding a new one
src/proxy.rs axum fallback handler: buffers request body (for session metadata,
tool results, and user prompts — `app::record_user_prompt` lifts
the trailing user message into a Kind::User feed entry verbatim
@@ -142,7 +146,15 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
recognise the pane's own traffic (see the embed-identity invariant).
`cc_default_model` reads Claude Code's *own* configured default
model out of its settings — the only persisted record of a
`[1m]` pick (see the 1M-context invariant)
`[1m]` pick (see the 1M-context invariant). `EmbeddedTerm::adopt`
rebuilds a pane around an inherited pty fd + pid after a hot
reload (`AdoptedMaster` / `PidKiller` stand in for the
portable-pty handles, which do not survive an exec)
src/reload.rs hot reload: ctrl-r `execve`s the binary now on disk *into this
process* — same pid, so the listener socket, the `claude` child
and (via a JSON snapshot) the live feed all cross over. Builds
nothing itself: you rebuild outside and press ctrl-r. See the
hot-reload invariant
```
Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state →
@@ -150,6 +162,65 @@ UI thread redraws on its own tick (no channel; just the mutex).
## Key invariants
- **A hot reload is an `execve` of ourselves, never a restart.** `reload.rs`
builds nothing and watches nothing: you rebuild however you normally would,
then **ctrl-r** swaps each running instance onto the binary now at
`App::exe`. Separating the two is the point — a build is the user's business,
and a running instance must not decide on its own when to become different
code. So there is no watcher thread, no `cargo` subprocess and no env var to
arm; ctrl-r is simply always live, in a debug build and a release one alike.
ctrl-r execs the same **path** it started from, so the reload follows the
file, not the profile: a debug instance reloads onto a rebuilt debug binary,
a release instance onto a rebuilt release one.
`execve` keeps the pid, the open fds and the child processes,
which is the whole reason all three things survive: the **port** (the accept
socket is inherited by fd number — `App::listener_fd` is a *dup*, so axum's
graceful shutdown can drop its own listener without ever closing the socket),
the **pane** (still our child, still on the same pty —
`term::PtyHandoff`/`EmbeddedTerm::adopt`), and the **feed** (a JSON snapshot
in `$TMPDIR`, pointed to by `CT_RELOAD_HANDOFF`). Four rules keep it honest:
1. **Resolve the exe path at startup, never lazily.** The file is *expected*
to have been replaced by the time ctrl-r is pressed, and a linker's rename
unlinks the inode we are running from — after which `/proc/self/exe` reads
`…/claude-cloak (deleted)`. `reload::exe_path` runs once, in `App::new`,
and strips that suffix defensively.
2. **Drain before exec.** The exec destroys the tokio tasks relaying
in-flight responses, so `proxy::run` serves `with_graceful_shutdown` and
the exec waits for it (`App::drain_tx``App::drained`, capped by
`DRAIN_MAX`). Counting `Session::active` is *not* the gate — it misses
untapped traffic (`count_tokens`, non-streaming posts) and races
`Tap::drop`, which runs on the tee task after the relay is done.
Meanwhile the socket stays open, so requests Claude Code makes *during*
the swap queue in the kernel backlog and are served by the new image:
verified end-to-end — nothing refused, nothing truncated.
3. **The snapshot is advisory, and split.** It is written by the old binary
and read by the new one, whose types usually just changed — that is the
normal case, not the edge case. So `Handoff` keeps the fd numbers in plain
fields and leaves the feed as an undecoded `serde_json::Value`, decoded
per session, each one passed through `sanitize_session`. Losing the feed
must never cost the port or the pane. `Entry::lane`, `Lane::anchor`,
`Lane::first_entry` and `Session::tool_ids` are raw indices that
`#[serde(default)]` does *not* protect, so they are validated once there
rather than defensively at every use site; `active` counters are zeroed
(nothing streams into a process that no longer exists) and
`app::seed_server_tool_seq` pushes the process-global `srvtool-` counter
past whatever the restored lanes already hold.
4. **Nothing is dropped on the way out.** An exec runs no destructors, which
is exactly why `EmbeddedTerm::drop` does not fire and SIGHUP the child —
so `try_reload` *borrows* the pane and the app rather than taking them,
and a failed exec (ctrl-r landing mid-link is the realistic case) leaves
the old code running with everything intact, `close_on_exec` having put
the FD_CLOEXEC flags back.
Two things the exec breaks that have to be repaired by hand: crossterm caches
the pre-raw termios in a process global, so `disable_raw_mode` runs *before*
the exec or the new image records raw as the original and hands the shell
back in raw mode; and ratatui diffs its first frame against an empty buffer,
so the incoming image clears the screen once. The alternate screen is
deliberately **not** left — re-entering it is a no-op and avoids a flash.
`Instant` has no epoch, so the three snapshotted ones travel as "ms ago"
(`reload::ms_ago`) — otherwise every restored lane would read as freshly
active. `App::embed_clear_at` points *forward* and is simply not carried.
- **Latency-neutral pass-through**: response bytes are forwarded as-is, never
buffered or rewritten. Auth headers pass through untouched. If the tap code
panics or misparses, the proxy must still relay bytes (tee is best-effort):
@@ -539,6 +610,9 @@ agentId: <hex>`), and the real completion is injected into the parent's next
it also owns the wheel (`ui::wheel`), so no pointer hit-testing is involved.
Switching the displayed session clears `App::lane_cols` and closes the popup,
so a lane id can't inherit another session's scroll offset.
ctrl-r hot-reloads onto the binary now on disk (you rebuild outside; this
swaps the running instance onto it) — global like ctrl-q, because it has to
work while the pane holds focus.
`CT_DEBUG_KEYS=1` shows raw key events in the status bar.
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
left-drag selects screen text, copied on release via OSC 52 (like Claude
@@ -597,6 +671,17 @@ agentId: <hex>`), and the real completion is injected into the parent's next
## Not yet handled (known MVP limits)
- Hot reload is unix-only (`execve`, fd inheritance, `TIOCSWINSZ`), and only
the UI path offers it — `--headless` has no event loop to press ctrl-r in.
It follows the *path* the instance was started from, so it cannot cross
profiles: reloading a debug instance onto a release build means starting the
release binary instead. `App::history` (viewed disk transcripts), the render
caches and the turn-tree expansion are not snapshotted — all are lazily
rebuilt. The adopted
pane loses Ink's `<Static>` transcript, because the repaint is a SIGWINCH and
Ink only redraws the live frame; for a prompt-only pane that is the intended
end state anyway.
- Non-streaming requests pass through untapped (e.g. `count_tokens`), and so
does a **2xx** non-SSE response. A non-2xx now surfaces as a `Kind::Error`.
- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts).

1
Cargo.lock generated
View File

@@ -353,6 +353,7 @@ dependencies = [
"anyhow",
"axum",
"futures-util",
"libc",
"portable-pty",
"ratatui",
"reqwest",

View File

@@ -21,3 +21,7 @@ portable-pty = "0.9"
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
wezterm-surface = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
uuid = { version = "1", features = ["v4"] }
# Hot reload (src/reload.rs): fd inheritance across execve, PTY ioctls and
# child signalling once the portable-pty handles are gone.
libc = "0.2"

View File

@@ -60,3 +60,36 @@ Sessions are keyed by the Claude Code session ID found in request metadata;
concurrent requests (subagents) tap independently.
`--headless` runs the proxy without the TUI. `CT_PORT` overrides the port (default 8484).
## Hot reload
Swap a running instance onto a newly built binary with **ctrl-r** — without
losing the proxy port, the embedded `claude` pane, or the live feed.
```sh
cargo build # in any terminal, whenever you like
# then press ctrl-r in each running instance
```
claude-cloak never builds anything itself and watches no files. You rebuild the
way you always would; ctrl-r says "run that one now".
ctrl-r execs the same **path** the instance was started from, so a debug
instance reloads onto a rebuilt debug binary and a release instance onto a
rebuilt release one. It does not cross profiles.
### How it survives
The app `execve`s the new binary into its **own process**, so the pid, the open
file descriptors and the child processes all stay. Before the exec the proxy
drains: it stops accepting and lets in-flight responses finish, while the
listening socket stays open so requests made during the swap wait in the kernel
backlog and are served by the new code. Nothing is refused and nothing is
truncated.
The footer shows `⟳ reloading…` while it drains, then `· reload #1` once the new
code is running. A failed exec — ctrl-r pressed while the linker still had the
file open — reports `⚠ reload failed: …` and changes nothing; press it again.
A state snapshot the new types no longer fit costs the feed only — never the
port and never the pane.

View File

@@ -142,6 +142,33 @@ pub struct App {
/// of which session id Claude Code reports, then binds `embed_session` to
/// that id. Set on spawn, cleared on kill.
pub embed_token: Option<String>,
/// Hot reload (`reload.rs`): idle, draining, or failed. ctrl-r starts it,
/// the UI thread finishes it.
pub reload: crate::reload::Status,
/// Our own executable path, read **once at startup**. ctrl-r execs exactly
/// this path, whatever is at it by then — that file is expected to have
/// been rebuilt behind our back, which is also why the path cannot be
/// resolved lazily (see `reload::exe_path`).
pub exe: std::path::PathBuf,
/// A **dup** of the proxy's accept socket, kept so a reload can hand the
/// still-open port to the new image. A dup on purpose: the graceful
/// shutdown drops axum's own listener, and this fd is what keeps the socket
/// — and the kernel backlog of connections Claude Code opens during the
/// swap — alive until the new image accepts on it.
pub listener_fd: std::os::fd::RawFd,
/// Tells the proxy to stop accepting and let in-flight responses finish.
/// Taken, not cloned: it fires exactly once, on the reload path.
pub drain_tx: Option<tokio::sync::oneshot::Sender<()>>,
/// Set by the proxy task once `axum::serve` has returned — i.e. every
/// connection task is finished. The exec waits for this, which is what
/// makes a reload invisible to Claude Code.
pub drained: Arc<std::sync::atomic::AtomicBool>,
/// Reloads this process has survived. Status bar only.
pub reload_gen: u32,
/// Sessions the last reload could not restore (their snapshot no longer
/// fit the current types). Surfaced next to `reload_gen` so a silently
/// shorter session list always has a visible reason.
pub reload_dropped: usize,
/// Mirror of the UI's "the claude pane has keyboard focus" state, written
/// by `draw` each frame so the tap (running off-thread) can avoid yanking
/// the selection away from a pane the user is actively driving.
@@ -209,6 +236,13 @@ impl App {
scroll: 0,
follow: true,
status: "starting proxy…".into(),
reload: crate::reload::Status::default(),
exe: crate::reload::exe_path(),
listener_fd: -1,
drain_tx: None,
drained: Arc::new(std::sync::atomic::AtomicBool::new(false)),
reload_gen: 0,
reload_dropped: 0,
filters: [true; FILTER_LABELS.len()],
filter_popup: None,
model_popup: None,
@@ -333,6 +367,32 @@ impl App {
.collect()
}
/// Tapped requests currently streaming, across every session and lane.
/// Display only — the reload gate is the proxy's graceful drain, which also
/// covers traffic no `Tap` ever sees (`count_tokens`, non-streaming posts).
/// Counting alone would miss those *and* race `Tap::drop`, which runs on
/// the tee task after the relay has already finished.
pub fn in_flight(&self) -> usize {
self.sessions.iter().map(|s| s.active).sum()
}
/// Re-establish what a reload snapshot cannot carry. Lane view state and
/// the agent popup are keyed by `LaneId` *within the displayed session*, so
/// they are cleared rather than guessed; the selection is re-resolved by
/// key, because disk stubs are rescanned asynchronously and their indices
/// are not stable across the restart.
pub fn after_restore(&mut self, key: Option<&str>) {
self.lane_cols.clear();
self.cols_session.clear();
self.agent_popup = None;
self.filter_popup = None;
self.model_popup = None;
self.expanded = None;
if let Some(k) = key {
self.select_key(k);
}
}
/// Length of the merged selection list: live sessions first (indices
/// stay stable — entries/sessions are append-only), then disk stubs.
pub fn merged_len(&self) -> usize {
@@ -860,9 +920,21 @@ pub const SERVER_TOOL_PREFIX: &str = "srvtool-";
/// counter is process-global and monotonic, so an id is never reused and a
/// `LaneId` stays valid forever (lanes remain append-only).
pub fn next_server_tool_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
format!("{SERVER_TOOL_PREFIX}{}", SEQ.fetch_add(1, Ordering::Relaxed))
let n = SRVTOOL_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("{SERVER_TOOL_PREFIX}{n}")
}
static SRVTOOL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Push the synthetic-lane counter forward to at least `next`.
///
/// A hot reload replaces the process image, so this counter would restart at 0
/// while the restored sessions still hold `srvtool-0` in `lane_of_agent` — the
/// next hosted web search would then stream into the *old, finished* lane
/// instead of a fresh one. `reload::restore` seeds it past everything it
/// restored.
pub fn seed_server_tool_seq(next: u64) {
SRVTOOL_SEQ.fetch_max(next, std::sync::atomic::Ordering::Relaxed);
}
/// Footer message while the agent picker is up.
@@ -910,6 +982,8 @@ pub const LANE_IDLE_MAX: std::time::Duration = std::time::Duration::from_secs(60
/// subagent. Entries stay in the session's single append-only `Vec<Entry>`
/// tagged with their lane; a `Lane` holds the per-agent state that used to sit
/// on `Session` and was therefore clobbered whenever a subagent streamed.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Lane {
/// `x-claude-code-agent-id`; empty for the main lane.
pub agent_id: String,
@@ -936,6 +1010,7 @@ pub struct Lane {
/// Last SSE event seen on this lane. `None` for a lane read from disk (it
/// was never live here), which is what keeps such a lane out of the popup's
/// running group.
#[serde(with = "crate::reload::ms_ago_opt")]
pub last_event: Option<Instant>,
pub input_tokens: u64,
pub output_tokens: u64,
@@ -970,9 +1045,18 @@ pub struct Lane {
/// tool_result comes back at launch time and says so, and the completion
/// arrives later as a `<task-notification>`. Never closes the lane itself —
/// a finished agent can still be woken by `SendMessage`.
#[serde(with = "crate::reload::ms_ago_opt")]
pub finished_at: Option<Instant>,
}
impl Default for Lane {
/// Only `#[serde(default)]` uses this: it lets a reload snapshot written
/// before a field existed still restore the rest of the lane.
fn default() -> Self {
Self::new(String::new(), String::new(), None, 0)
}
}
impl Lane {
/// Is this agent running *right now*? Only the popup's list order depends
/// on it (running agents sort first, and the newest running one is
@@ -1051,6 +1135,8 @@ impl Lane {
}
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Session {
pub key: String,
pub entries: Vec<Entry>,
@@ -1061,6 +1147,7 @@ pub struct Session {
pub lane_of_agent: HashMap<String, LaneId>,
/// In-flight taps across all lanes (the session's "running" dot).
pub active: usize,
#[serde(with = "crate::reload::ms_ago")]
pub last_activity: Instant,
/// tool_use id → entry index, so results arriving in the *next* request
/// body can be attached to the tool entry they belong to. Session-global:
@@ -1082,6 +1169,14 @@ pub struct Session {
pub long_context: Option<bool>,
}
impl Default for Session {
/// Only `#[serde(default)]` uses this: it lets a reload snapshot written
/// before a field existed still restore the rest of the session.
fn default() -> Self {
Self::new(String::new(), String::new())
}
}
impl Session {
pub fn new(key: String, model: String) -> Self {
Self {
@@ -1690,7 +1785,7 @@ fn fmt_duration(ms: u64) -> String {
}
}
#[derive(PartialEq)]
#[derive(PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub enum Kind {
/// A user-submitted prompt, lifted from the request body (the trailing
/// `user` message of a turn-starting request) — not part of the SSE stream.
@@ -1698,6 +1793,9 @@ pub enum Kind {
Thinking,
Text,
Tool { name: String },
/// Also the `Default`, which exists only so a reload snapshot written by
/// an older binary still deserializes when it predates a `Kind` field.
#[default]
Meta,
Error,
/// A `<system-reminder>` Claude Code injects into a user turn. Kept (shown
@@ -1721,6 +1819,8 @@ pub enum Kind {
TaskNote,
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Entry {
pub kind: Kind,
pub content: String,
@@ -1732,6 +1832,8 @@ pub struct Entry {
pub lane: LaneId,
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ToolResult {
pub content: String,
pub is_error: bool,

View File

@@ -2,18 +2,40 @@ mod ansi;
mod app;
mod markdown;
mod proxy;
mod reload;
mod sessions;
mod sse;
mod term;
mod ui;
use app::App;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
fn main() -> anyhow::Result<()> {
let headless = std::env::args().any(|a| a == "--headless");
let app = Arc::new(Mutex::new(App::new()));
// A hot reload execs the new binary into this same process (see
// reload.rs), so a handoff here means "we are the new code and everything
// the old code owned is still open": the listener socket, the pty of the
// embedded pane, and a snapshot of the feed.
let handoff = reload::take_handoff();
let mut state = App::new();
let mut pane = None;
let mut pane_ui = reload::PaneState::default();
let reloaded = handoff.is_some();
if let Some(h) = handoff {
state.reload_gen = h.generation + 1;
state.listener_fd = h.listener_fd;
// Best-effort: a snapshot the new types no longer fit costs the feed,
// never the port and never the pane (they are plain numbers above).
reload::restore(&mut state, h.app);
pane = h.pane;
pane_ui = h.pane_ui;
}
let app = Arc::new(Mutex::new(state));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
@@ -21,22 +43,34 @@ fn main() -> anyhow::Result<()> {
// Bind up front so every instance gets its own port: CT_PORT pins it
// (hard error if taken), otherwise prefer 8484 and fall back to an
// OS-assigned free port so multiple instances can coexist.
let listener = rt.block_on(async {
match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await,
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
Ok(l) => Ok(l),
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await,
},
}
})?;
//
// A reload skips all of that and adopts the socket the previous image was
// already accepting on, so the port never closes and Claude Code never
// sees a refused connection.
let inherited = app::lock_app(&app).listener_fd;
let listener = rt.block_on(open_listener(inherited))?;
let port = listener.local_addr()?.port();
// Keep a dup for the *next* reload: the graceful shutdown drops axum's own
// listener, and this fd is what holds the socket open across the exec.
let keep_fd = reload::dup_listener(&listener)?;
let (drain_tx, drain_rx) = tokio::sync::oneshot::channel();
let drained = Arc::new(AtomicBool::new(false));
{
let mut a = app::lock_app(&app);
a.listener_fd = keep_fd;
a.drain_tx = Some(drain_tx);
a.drained = drained.clone();
}
let papp = app.clone();
let proxy_handle = rt.spawn(async move {
if let Err(e) = proxy::run(papp.clone(), listener).await {
if let Err(e) = proxy::run(papp.clone(), listener, drain_rx).await {
app::lock_app(&papp).status = format!("proxy failed: {e}");
}
// `serve` has returned, so every connection task is finished. This is
// the reload's go-ahead.
drained.store(true, Ordering::SeqCst);
});
if headless {
@@ -44,8 +78,26 @@ fn main() -> anyhow::Result<()> {
rt.block_on(proxy_handle)?;
Ok(())
} else {
let r = ui::run(app, port);
let r = ui::run(app, port, pane, pane_ui, reloaded);
rt.shutdown_background();
r
}
}
/// The proxy's accept socket: adopted from the previous image after a hot
/// reload, freshly bound otherwise.
async fn open_listener(inherited: std::os::fd::RawFd) -> anyhow::Result<tokio::net::TcpListener> {
if let Ok(std) = reload::adopt_listener(inherited)
&& let Ok(l) = tokio::net::TcpListener::from_std(std)
{
return Ok(l);
}
let l = match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await?,
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
Ok(l) => l,
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await?,
},
};
Ok(l)
}

View File

@@ -71,13 +71,45 @@ struct Ctx {
app: SharedApp,
}
pub async fn run(app: SharedApp, listener: tokio::net::TcpListener) -> anyhow::Result<()> {
/// Serve until `drain` fires, then finish every in-flight response and return.
///
/// The drain signal is the hot-reload handshake (`reload.rs`): axum stops
/// accepting, closes idle keep-alive connections and lets streaming responses
/// run to completion, so the exec that follows can never truncate one. The
/// socket itself stays open the whole time — `App::listener_fd` holds a dup of
/// it — so connections Claude Code opens during the swap queue in the kernel
/// backlog and are served by the new image.
pub async fn run(
app: SharedApp,
listener: tokio::net::TcpListener,
drain: tokio::sync::oneshot::Receiver<()>,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder().build()?;
let ctx = Ctx { client, app: app.clone() };
let router = Router::new().fallback(forward).with_state(ctx);
let port = listener.local_addr()?.port();
lock_app(&app).status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
axum::serve(listener, router).await?;
{
// Written here rather than in `main` because this line is the last one
// to land at startup — a reload note set earlier would be overwritten
// by it. The port is the same one across a reload; the counter is the
// only visible sign that the code under it changed.
let mut a = lock_app(&app);
let note = match (a.reload_gen, a.reload_dropped) {
(0, _) => String::new(),
(n, 0) => format!(" · reload #{n}"),
(n, d) => format!(" · reload #{n} ({d} session(s) not restored)"),
};
a.status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com{note}");
}
axum::serve(listener, router)
.with_graceful_shutdown(async move {
// A dropped sender (no reload was ever started) simply never
// resolves into a shutdown — `Err` means "hold the door open".
if drain.await.is_err() {
std::future::pending::<()>().await;
}
})
.await?;
Ok(())
}

626
src/reload.rs Normal file
View File

@@ -0,0 +1,626 @@
//! Hot reload: `execve` the binary on disk *into this process* instead of
//! restarting.
//!
//! This module builds nothing and watches nothing. You rebuild however you
//! normally would — `cargo build`, `cargo build --release`, a script — and then
//! press **ctrl-r** in each running instance to swap it onto the new binary.
//! Separating the two is the point: a build is your business, and a running
//! instance should not decide on its own when to become different code.
//!
//! `execve` replaces the program image but keeps the pid, the open file
//! descriptors and the child processes. That is the whole trick, and it is what
//! lets all three things the proxy cares about survive:
//!
//! - **the listener** — the accept socket is inherited by fd number, so the
//! port is never closed and never rebound. Claude Code keeps talking to the
//! same `127.0.0.1:<port>` across the swap;
//! - **the embedded `claude` pane** — still our child, still on the same pty
//! (`term::PtyHandoff` / `EmbeddedTerm::adopt`). It is never told anything; it
//! just gets a repaint;
//! - **the live feed** — sessions/entries/lanes travel in a JSON snapshot.
//!
//! Two rules keep it honest:
//!
//! 1. **Drain before the exec.** It destroys the tokio tasks relaying in-flight
//! responses, so the proxy is asked to stop accepting and finish what it has
//! first. The socket stays open throughout, so requests made during the swap
//! queue in the kernel backlog and are served by the new image.
//! 2. **The snapshot is advisory.** It is written by the *old* binary and read
//! by the *new* one, whose types may have just changed — the normal case
//! when the reason you rebuilt was editing `app.rs`. Every restore step is
//! best-effort: a snapshot that no longer fits costs the feed, never the
//! port and never the pane.
//!
//! Nothing here talks to the network, and nothing here runs a subprocess.
use anyhow::Context;
use crate::app::App;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Env var pointing the new image at its snapshot file. Its presence is what
/// distinguishes "started by a reload" from "started by the user".
const HANDOFF_ENV: &str = "CT_RELOAD_HANDOFF";
// ---------------------------------------------------------------------------
// Relative-time serde for the `Instant` fields on Session/Lane
// ---------------------------------------------------------------------------
/// `Instant` has no absolute epoch, so it is snapshotted as "this many ms ago"
/// and rebuilt against the new image's clock. Idle/liveness logic
/// (`Lane::running`, `LANE_IDLE_MAX`) therefore reads the same before and after
/// a reload instead of every lane looking freshly active.
pub mod ms_ago {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::{Duration, Instant};
pub fn serialize<S: Serializer>(v: &Instant, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(v.elapsed().as_millis() as u64)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Instant, D::Error> {
let ms = u64::deserialize(d)?;
Ok(back(ms))
}
/// `checked_sub` because a monotonic clock that has not been running long
/// enough cannot represent the age — then "now" is the closest truth.
pub(crate) fn back(ms: u64) -> Instant {
let now = Instant::now();
now.checked_sub(Duration::from_millis(ms)).unwrap_or(now)
}
}
/// `ms_ago` for an `Option<Instant>`.
pub mod ms_ago_opt {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Instant;
pub fn serialize<S: Serializer>(v: &Option<Instant>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(i) => s.serialize_some(&(i.elapsed().as_millis() as u64)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Instant>, D::Error> {
Ok(Option::<u64>::deserialize(d)?.map(super::ms_ago::back))
}
}
// ---------------------------------------------------------------------------
// Reload status (owned by App, rendered in the status bar)
// ---------------------------------------------------------------------------
#[derive(Default, Clone, PartialEq)]
pub enum Status {
/// Nothing in progress. ctrl-r starts a reload from here.
#[default]
Idle,
/// The proxy is finishing its in-flight responses; the exec follows.
Draining(std::time::Instant),
/// The exec failed, so the old code is still running and still serving.
/// Purely informational — usually "you pressed ctrl-r mid-build".
Failed(String),
}
impl Status {
/// One-line status-bar rendering, or `None` when there is nothing to say.
pub fn note(&self, in_flight: usize) -> Option<String> {
match self {
Status::Idle => None,
Status::Draining(_) if in_flight > 0 => {
Some(format!("⟳ reloading · draining {in_flight} turn(s)…"))
}
Status::Draining(_) => Some("⟳ reloading…".into()),
Status::Failed(e) => Some(format!("⚠ reload failed: {e}")),
}
}
}
/// Cap on the graceful drain. A stuck upstream response must not pin the
/// reload forever; past this the exec happens anyway and that one response is
/// cut — the same outcome as no drain at all, just far less likely.
pub const DRAIN_MAX: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// The snapshot handed across the exec
// ---------------------------------------------------------------------------
/// Written by the outgoing image, read by the incoming one. The fd numbers in
/// here are only meaningful because `keep_open` cleared their FD_CLOEXEC.
///
/// Read side only — the write side is `HandoffRef`, which borrows the live
/// state instead of moving it, so a failed exec leaves the running app whole.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct Handoff {
/// Inherited accept socket. `-1` means "bind a new one" (should not happen).
pub listener_fd: RawFd,
/// The embedded pane, when there was a live one.
pub pane: Option<crate::term::PtyHandoff>,
/// The feed, **left unparsed on purpose**. Schema churn is the normal case
/// for a dev tool — the edit that triggered the reload is usually the one
/// that changed these types — and parsing it inline would let one renamed
/// field take the port and the pane down with the feed. It is decoded
/// separately, session by session, in `restore`.
pub app: serde_json::Value,
pub pane_ui: PaneState,
/// Reloads so far, for the status line.
pub generation: u32,
}
/// The part of `App` worth carrying over. Deliberately a separate struct rather
/// than `#[derive(Serialize)] on App`: popups, caches and lazily loaded disk
/// views are cheap to rebuild and would only add schema churn.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct AppState {
/// One `Value` per session, decoded individually: a session that no longer
/// fits is skipped instead of discarding the whole feed.
pub sessions: Vec<serde_json::Value>,
/// Session the highlight was on. Preferred over `selected`: disk stubs are
/// rescanned asynchronously, so their indices are not stable across the
/// exec, but their uuids are.
pub selected_key: Option<String>,
pub selected: usize,
pub scroll: usize,
pub follow: bool,
pub filters: Vec<bool>,
pub show_sessions: bool,
pub embed_session: Option<String>,
pub embed_token: Option<String>,
}
/// The pane-related UI state that lives on `EmbedUi`, not on `App`.
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PaneState {
pub visible: bool,
pub focused: bool,
pub fullscreen: bool,
pub past_embeds: Vec<String>,
pub compact_inner: u16,
}
// ---------------------------------------------------------------------------
// Startup side: did a reload just hand us the process?
// ---------------------------------------------------------------------------
/// Read (and consume) the handoff this process was exec'd with. Returns `None`
/// for a normal start, and also for a snapshot that no longer parses — an
/// expected outcome when the edit that triggered the reload changed the state
/// types, and the reason this is `Option` rather than `Result`.
pub fn take_handoff() -> Option<Handoff> {
let path = std::env::var_os(HANDOFF_ENV)?;
// Consume it either way: a stale file must never be picked up twice.
// Sound here and nowhere else — `main` has not spawned a thread yet.
unsafe { std::env::remove_var(HANDOFF_ENV) };
let raw = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
let parsed = raw.as_ref().and_then(|b| serde_json::from_slice::<Handoff>(b).ok());
if parsed.is_none() {
// Without the fd numbers the inherited socket and pty are unusable
// (still open, but anonymous), so the kernel closes them when we exit.
// A fresh bind is the safe outcome.
eprintln!("claude-cloak: reload handoff unreadable, starting fresh");
}
parsed
}
/// Rebuild a `std::net::TcpListener` from the inherited fd. The caller converts
/// it to a tokio listener inside the runtime.
///
/// The fd is verified to be a listening socket first: the number comes out of a
/// file on disk, and a stale handoff could name an fd this process has since
/// reused for something else entirely.
pub fn adopt_listener(fd: RawFd) -> anyhow::Result<std::net::TcpListener> {
anyhow::ensure!(fd >= 0, "no inherited listener");
anyhow::ensure!(is_listening(fd), "inherited fd {fd} is not a listening socket");
// Put CLOEXEC back: from here on it is a normal socket again, and the next
// reload clears the flag itself.
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let l = unsafe { std::net::TcpListener::from_raw_fd(fd) };
l.set_nonblocking(true)?;
Ok(l)
}
fn is_listening(fd: RawFd) -> bool {
let mut on: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ACCEPTCONN,
(&raw mut on).cast(),
&mut len,
)
};
rc == 0 && on != 0
}
/// Duplicate the accept socket so the reload can hold the port open while
/// axum's own listener is dropped by the graceful shutdown.
pub fn dup_listener(l: &tokio::net::TcpListener) -> anyhow::Result<RawFd> {
let fd = unsafe { libc::fcntl(l.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup listener");
}
Ok(fd)
}
/// Apply a restored snapshot to a fresh `App`. Every step is best-effort by
/// design — see the module docs.
pub fn restore(app: &mut App, raw: serde_json::Value) {
let s: AppState = serde_json::from_value(raw).unwrap_or_default();
let total = s.sessions.len();
app.sessions = s
.sessions
.into_iter()
.filter_map(|v| serde_json::from_value::<crate::app::Session>(v).ok())
.filter_map(sanitize_session)
.collect();
let kept = app.sessions.len();
app.selected = s.selected;
app.scroll = s.scroll;
app.follow = s.follow;
app.show_sessions = s.show_sessions;
app.embed_session = s.embed_session;
app.embed_token = s.embed_token;
// Length-tolerant: the filter set grows as entry kinds are added, and a
// snapshot from before that must not shift the toggles.
for (i, v) in s.filters.iter().take(app.filters.len()).enumerate() {
app.filters[i] = *v;
}
// The synthetic-lane counter is process-global; restart it past whatever
// the restored sessions already use.
crate::app::seed_server_tool_seq(max_server_tool_seq(&app.sessions) + 1);
app.after_restore(s.selected_key.as_deref());
app.reload_dropped = total - kept;
}
/// Make one restored session safe to render.
///
/// `Entry::lane`, `Lane::first_entry`, `Lane::anchor` and the values of
/// `Session::tool_ids` are all raw indices into vectors that a schema change
/// can shorten. `#[serde(default)]` covers a *missing* field and does nothing
/// for an *inconsistent* one, and the first draw indexes them directly — so
/// they are checked here, once, instead of defensively at every use site.
fn sanitize_session(mut s: crate::app::Session) -> Option<crate::app::Session> {
// `#[serde(default)]` is deliberately forgiving, which means an object that
// is not a session at all still decodes — into an empty one. A live session
// is always keyed (`proxy::forward_inner` falls back to `"unknown"`), so an
// empty key is the tell.
if s.key.is_empty() || s.lanes.is_empty() {
return None;
}
let lanes = s.lanes.len();
let entries = s.entries.len();
s.entries.retain(|e| (e.lane as usize) < lanes);
// Retaining shifts positions, so any index into `entries` is only sound
// when nothing was dropped.
let shifted = s.entries.len() != entries;
let entries = s.entries.len();
// Nothing is streaming into a process that no longer exists. Leaving these
// set would show a permanent running dot and keep `Lane::running` true
// forever — and `Tap::drop` runs on the tee task, so the snapshot can
// legitimately have caught a count that was about to be decremented.
s.active = 0;
for l in &mut s.lanes {
l.active = 0;
if shifted || l.first_entry.is_some_and(|i| i >= entries) {
l.first_entry = None;
}
if shifted || l.anchor.is_some_and(|i| i >= entries) {
l.anchor = None;
}
}
// A tool call whose result can no longer be attached is better than one
// attached to the wrong entry.
s.tool_ids.retain(|_, i| !shifted && *i < entries);
// A half-streamed entry never gets its remaining deltas: close it out so it
// renders as finished markdown instead of a permanently pending block.
if let Some(e) = s.entries.last_mut() {
e.done = true;
}
Some(s)
}
/// Highest `srvtool-<n>` index across the restored sessions.
fn max_server_tool_seq(sessions: &[crate::app::Session]) -> u64 {
sessions
.iter()
.flat_map(|s| s.lane_of_agent.keys())
.filter_map(|k| k.strip_prefix(crate::app::SERVER_TOOL_PREFIX))
.filter_map(|n| n.parse::<u64>().ok())
.max()
.unwrap_or(0)
}
/// Write side of `Handoff`. It *borrows* the live app: the feed can be tens of
/// megabytes, and — more importantly — an exec that fails must leave the
/// running process exactly as it was, which a moved-out `Vec<Session>` would
/// not. The field names mirror `Handoff`/`AppState` exactly; that is the whole
/// contract between the two.
#[derive(serde::Serialize)]
struct HandoffRef<'a> {
listener_fd: RawFd,
pane: &'a Option<crate::term::PtyHandoff>,
app: AppStateRef<'a>,
pane_ui: &'a PaneState,
generation: u32,
}
#[derive(serde::Serialize)]
struct AppStateRef<'a> {
sessions: &'a [crate::app::Session],
selected_key: Option<String>,
selected: usize,
scroll: usize,
follow: bool,
filters: &'a [bool],
show_sessions: bool,
embed_session: &'a Option<String>,
embed_token: &'a Option<String>,
}
impl<'a> AppStateRef<'a> {
fn of(app: &'a App) -> Self {
Self {
sessions: &app.sessions,
selected_key: app.selected_key(),
selected: app.selected,
scroll: app.scroll,
follow: app.follow,
filters: &app.filters,
show_sessions: app.show_sessions,
embed_session: &app.embed_session,
embed_token: &app.embed_token,
}
}
}
// ---------------------------------------------------------------------------
// Commit side: exec ourselves
// ---------------------------------------------------------------------------
/// Clear FD_CLOEXEC so `fd` survives the coming `execve`. Everything else we
/// hold keeps the flag and is closed by the kernel, which is what we want:
/// only the listener and the pty master are meant to cross over.
fn keep_open(fd: RawFd) -> bool {
unsafe { libc::fcntl(fd, libc::F_SETFD, 0) == 0 }
}
/// Undo `keep_open`. Only reached when the exec failed: the fds must not stay
/// inheritable, or the next `claude` we spawn would get a copy of the proxy
/// socket and the pane's pty.
fn close_on_exec(fd: RawFd) {
if fd >= 0 {
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
}
}
/// Replace this process with `exe`, carrying the live state across.
///
/// On success this never returns: the new image continues from `main` with the
/// same pid, the same listener socket and the same `claude` child. It only
/// returns on failure — and then nothing has been consumed, so the caller just
/// keeps running the old code.
pub fn exec_into(
exe: &Path,
app: &App,
pane: Option<crate::term::PtyHandoff>,
pane_ui: &PaneState,
) -> anyhow::Error {
// Only these two fds are meant to cross. Everything else we hold keeps its
// FD_CLOEXEC and is closed by the kernel during the exec — including the
// established connections, which is why the reload waits for a quiet wire.
let listener_fd = if keep_open(app.listener_fd) { app.listener_fd } else { -1 };
let pane = match pane {
Some(p) if keep_open(p.master_fd) => Some(p),
// A pane whose fd cannot be kept open is dropped rather than handed
// over as a dangling number: the new image then simply has no pane.
_ => None,
};
let path = std::env::temp_dir().join(format!("claude-cloak-reload-{}.json", std::process::id()));
let snapshot = HandoffRef {
listener_fd,
pane: &pane,
app: AppStateRef::of(app),
pane_ui,
generation: app.reload_gen,
};
let written = serde_json::to_vec(&snapshot).map_err(anyhow::Error::from).and_then(|b| {
// Written and flushed before the exec: a crash in between would
// otherwise leave a truncated file the new image reads as garbage.
use std::io::Write;
let mut f = std::fs::File::create(&path)?;
f.write_all(&b)?;
f.sync_all()?;
Ok(())
});
let err = match written {
Err(e) => e.context("write reload snapshot"),
Ok(()) => {
// Same arguments we were started with, argv[0] aside.
let args: Vec<String> = std::env::args().skip(1).collect();
use std::os::unix::process::CommandExt;
let e = std::process::Command::new(exe).args(&args).env(HANDOFF_ENV, &path).exec();
// `exec` returns only on failure.
let _ = std::fs::remove_file(&path);
anyhow::Error::from(e).context(format!("exec {}", exe.display()))
}
};
// The reload did not happen, so put the fds back the way we found them and
// let the old code carry on.
close_on_exec(listener_fd);
if let Some(p) = &pane {
close_on_exec(p.master_fd);
}
err
}
// ---------------------------------------------------------------------------
// Which binary to exec
// ---------------------------------------------------------------------------
/// Resolve our own executable **path** (not inode) at startup.
///
/// Read once, in `main`, and then carried across every reload in the handoff —
/// because by the time you press ctrl-r the file has usually been replaced.
/// A linker writes the new binary and renames it over the old one, which
/// unlinks the inode we are running from; Linux then reports `/proc/self/exe`
/// as `…/claude-cloak (deleted)`. Resolving late would exec that literal name
/// and fail, so the suffix is also stripped defensively.
pub fn exe_path() -> PathBuf {
let raw = std::env::current_exe()
.ok()
.or_else(|| std::env::args_os().next().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(env!("CARGO_PKG_NAME")));
strip_deleted(raw)
}
fn strip_deleted(p: PathBuf) -> PathBuf {
match p.to_str().and_then(|s| s.strip_suffix(" (deleted)")) {
Some(s) => PathBuf::from(s),
None => p,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{Entry, Kind, Session};
use std::time::Instant;
/// A session as the *old* image would have snapshotted it.
fn live_session() -> Session {
let mut s = Session::new("sess".into(), "opus".into());
s.push(0, Entry { kind: Kind::Text, content: "hi".into(), ..Default::default() });
s.active = 2;
s.lanes[0].active = 2;
s.tool_ids.insert("toolu_1".into(), 0);
s
}
#[test]
fn snapshot_round_trips_through_json() {
let s = live_session();
let json = serde_json::to_string(&s).unwrap();
let back: Session = serde_json::from_str(&json).unwrap();
assert_eq!(back.key, "sess");
assert_eq!(back.entries.len(), 1);
assert_eq!(back.entries[0].content, "hi");
assert_eq!(back.lanes.len(), 1);
}
/// `Instant` is snapshotted as an age, so a lane restored from disk must
/// still look as old as it was — that is what `Lane::running` reads.
#[test]
fn instants_survive_as_ages_not_as_now() {
let mut s = live_session();
let old = Instant::now() - Duration::from_secs(120);
s.last_activity = old;
s.lanes[0].last_event = Some(old);
let back: Session = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert!(back.last_activity.elapsed() >= Duration::from_secs(119));
assert!(back.lanes[0].last_event.unwrap().elapsed() >= Duration::from_secs(119));
// …and therefore reads as idle, not as freshly running, once the
// in-flight counters are cleared.
assert!(!sanitize_session(back).unwrap().lanes[0].running());
}
/// Nothing streams into a process that no longer exists.
#[test]
fn sanitize_clears_in_flight_counters() {
let s = sanitize_session(live_session()).unwrap();
assert_eq!(s.active, 0);
assert_eq!(s.lanes[0].active, 0);
assert!(s.entries.last().unwrap().done, "a cut entry is closed out");
}
/// The dangerous case: a snapshot whose entries point at lanes the new
/// binary's session no longer has. Indexing those panics on the first draw.
#[test]
fn sanitize_drops_entries_with_dangling_lanes() {
let mut s = live_session();
s.entries.push(Entry { kind: Kind::Text, content: "ghost".into(), lane: 7, ..Default::default() });
let s = sanitize_session(s).unwrap();
assert_eq!(s.entries.len(), 1);
assert!(s.entries.iter().all(|e| (e.lane as usize) < s.lanes.len()));
// Positions shifted, so index-valued maps are dropped rather than
// left pointing at the wrong entry.
assert!(s.tool_ids.is_empty());
}
#[test]
fn sanitize_drops_a_session_without_lanes() {
let mut s = live_session();
s.lanes.clear();
assert!(sanitize_session(s).is_none());
}
#[test]
fn sanitize_clears_out_of_range_lane_indices() {
let mut s = live_session();
s.lanes[0].anchor = Some(99);
s.lanes[0].first_entry = Some(99);
let s = sanitize_session(s).unwrap();
assert_eq!(s.lanes[0].anchor, None);
assert_eq!(s.lanes[0].first_entry, None);
}
/// The synthetic-lane counter is process-global, so a reload must not
/// restart it at 0 and re-enter a lane the restored session already holds.
#[test]
fn server_tool_seq_is_seeded_past_restored_lanes() {
let mut s = live_session();
s.lane_of_agent.insert(format!("{}4", crate::app::SERVER_TOOL_PREFIX), 0);
s.lane_of_agent.insert("deadbeef".into(), 0);
assert_eq!(max_server_tool_seq(&[s]), 4);
}
/// The binary is expected to have been replaced while we run: a rename
/// over the running image makes Linux report `/proc/self/exe` with a
/// ` (deleted)` suffix, and exec'ing that literal name would fail.
#[test]
fn exe_path_drops_the_deleted_suffix() {
let p = strip_deleted(PathBuf::from("/x/target/debug/claude-cloak (deleted)"));
assert_eq!(p, PathBuf::from("/x/target/debug/claude-cloak"));
// An ordinary path is untouched, including one that merely contains
// the word.
let p = PathBuf::from("/x/deleted/claude-cloak");
assert_eq!(strip_deleted(p.clone()), p);
}
/// A handoff whose feed no longer parses must still surrender the fd
/// numbers: losing the code's state is survivable, losing the port and the
/// pane is not.
#[test]
fn unparseable_feed_keeps_the_port_and_the_pane() {
let raw = serde_json::json!({
"listener_fd": 9,
"pane": {
"master_fd": 11, "child_pid": 4242, "session_id": "s",
"pane_token": "t", "pty_rows": 20, "cols": 80
},
"app": {"sessions": [{"this": "is not a session"}, {"key": "keeper"}], "selected": 3},
"pane_ui": {"visible": true},
"generation": 2,
});
let h: Handoff = serde_json::from_value(raw).unwrap();
assert_eq!(h.listener_fd, 9);
assert_eq!(h.pane.as_ref().unwrap().child_pid, 4242);
assert_eq!(h.generation, 2);
let mut app = App::new();
restore(&mut app, h.app);
let keys: Vec<_> = app.sessions.iter().map(|s| s.key.as_str()).collect();
assert_eq!(keys, ["keeper"], "the junk entry is skipped, the real one kept");
}
}

View File

@@ -16,7 +16,8 @@ use ratatui::buffer::Buffer;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use std::io::Read;
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub use wezterm_surface::CursorShape;
@@ -84,6 +85,11 @@ pub struct EmbeddedTerm {
/// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16,
cols: u16,
/// The child's pid. Kept as a plain number because a hot reload
/// (`reload.rs`) execs us: the portable-pty `Child` handle dies with the
/// old image, but we stay the same process, so the *pid* is still ours to
/// wait on and signal after the exec.
child_pid: Option<u32>,
}
/// HTTP header carrying the pane token; the proxy reads it to bind this pane's
@@ -156,6 +162,7 @@ impl EmbeddedTerm {
.context("openpty")?;
let child = pty.slave.spawn_command(cmd).context("spawn child")?;
let killer = child.clone_killer();
let child_pid = child.process_id();
drop(pty.slave);
// The terminal model writes query responses (DSR/DA/XTGETTCAP…) and
@@ -194,13 +201,112 @@ impl EmbeddedTerm {
});
}
Ok(Self { term, master: pty.master, killer, exited, session_id, pane_token, pty_rows: rows + PTY_PAD, cols })
Ok(Self {
term,
master: pty.master,
killer,
exited,
session_id,
pane_token,
pty_rows: rows + PTY_PAD,
cols,
child_pid,
})
}
/// Take a *running* child back over after a hot reload. The PTY master fd
/// came through the `execve` (see `reload::keep_open`), and the child never
/// noticed: same pid on our side, same pty, same session.
///
/// What does not survive is the wezterm screen model: it is rebuilt empty,
/// so the cells have to come from the child again. The trick is to adopt
/// the pty **one row short** of its real height and let the first
/// `ui::draw` frame restore it — `resize` then sees a changed geometry and
/// issues a real `TIOCSWINSZ`, which Linux only turns into a SIGWINCH when
/// the size actually differs, and Ink answers with a full repaint. Poking
/// the ioctl twice in a row here instead would coalesce into one signal
/// carrying the *unchanged* final size, and redraw nothing.
pub fn adopt(h: PtyHandoff) -> anyhow::Result<Self> {
let rows = h.pty_rows.saturating_sub(1).max(1);
let master = AdoptedMaster { fd: unsafe { OwnedFd::from_raw_fd(h.master_fd) } };
// The fd arrived non-CLOEXEC (that is how it survived the exec). Put
// the flag back so it isn't inherited by anything we spawn from here.
unsafe { libc::fcntl(h.master_fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let writer = master.take_writer().context("pty writer")?;
let _ = master.resize(PtySize { rows, cols: h.cols, pixel_width: 0, pixel_height: 0 });
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: rows as usize,
cols: h.cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
},
Arc::new(Config),
"claude-cloak",
env!("CARGO_PKG_VERSION"),
writer,
)));
let exited = Arc::new(AtomicBool::new(false));
let mut reader = master.try_clone_reader().context("pty reader")?;
let pid = h.child_pid;
{
let term = term.clone();
let exited = exited.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
}
}
// Still the child's parent across the exec, so it is still
// ours to reap — the portable-pty `Child` that used to do it
// died with the old image. Retry on EINTR; anything else
// (notably ECHILD) means there is nothing left to wait for.
let mut status = 0;
while unsafe { libc::waitpid(pid as i32, &mut status, 0) } < 0
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
{}
exited.store(true, Ordering::Relaxed);
});
}
Ok(Self {
term,
master: Box::new(master),
killer: Box::new(PidKiller(pid)),
exited,
session_id: h.session_id,
pane_token: h.pane_token,
// Deliberately the short height: the next frame's `resize` restores
// the real one and that is what triggers the repaint.
pty_rows: rows,
cols: h.cols,
child_pid: Some(pid),
})
}
pub fn exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
/// Describe this pane well enough for the post-exec image to re-adopt it
/// (`adopt`). `None` when the child's pid is unknown or the master has no
/// fd — either way the pane can't survive a reload and is killed instead.
pub fn handoff(&self) -> Option<PtyHandoff> {
Some(PtyHandoff {
master_fd: self.master.as_raw_fd()?,
child_pid: self.child_pid?,
session_id: self.session_id.clone(),
pane_token: self.pane_token.clone(),
pty_rows: self.pty_rows,
cols: self.cols,
})
}
/// Resize PTY + terminal model for a pane of `rows` visible rows.
/// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows:
/// render() crops Claude Code's persistent status/hint rows, so the
@@ -680,6 +786,118 @@ fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usi
(end + 1 - h, end)
}
/// Everything the post-exec image needs to take a running `claude` child back
/// over (`EmbeddedTerm::adopt`). An `execve` keeps our pid, our open fds and
/// our children, so the child never notices the reload — but every Rust-side
/// handle is gone, which is why the pane is rebuilt from a bare fd + pid.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PtyHandoff {
/// PTY master. `reload::keep_open` clears its FD_CLOEXEC before the exec,
/// so this number is still valid — and still the same pty — afterwards.
pub master_fd: RawFd,
pub child_pid: u32,
pub session_id: String,
pub pane_token: String,
pub pty_rows: u16,
pub cols: u16,
}
/// A PTY master rebuilt from an inherited fd. Implements just enough of
/// `MasterPty` to stand in for portable-pty's own master, so `EmbeddedTerm`
/// keeps one type for both the freshly spawned and the adopted pane.
///
/// Note its writer is a plain `File`: portable-pty's writer sends EOT to the
/// child when dropped, which would end the adopted session on every teardown.
#[derive(Debug)]
struct AdoptedMaster {
fd: OwnedFd,
}
impl AdoptedMaster {
/// Duplicate the master fd for an independent reader/writer handle.
/// `F_DUPFD_CLOEXEC` keeps the clone out of the *next* reload's exec —
/// only the one fd named in the handoff is meant to survive.
fn dup(&self) -> anyhow::Result<std::fs::File> {
let fd = unsafe { libc::fcntl(self.fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup pty master");
}
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
}
}
impl MasterPty for AdoptedMaster {
fn resize(&self, size: PtySize) -> Result<(), anyhow::Error> {
let ws = libc::winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCSWINSZ)");
}
Ok(())
}
fn get_size(&self) -> Result<PtySize, anyhow::Error> {
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCGWINSZ as _, &mut ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCGWINSZ)");
}
Ok(PtySize {
rows: ws.ws_row,
cols: ws.ws_col,
pixel_width: ws.ws_xpixel,
pixel_height: ws.ws_ypixel,
})
}
fn try_clone_reader(&self) -> Result<Box<dyn Read + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn take_writer(&self) -> Result<Box<dyn Write + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn process_group_leader(&self) -> Option<libc::pid_t> {
match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } {
pid if pid > 0 => Some(pid),
_ => None,
}
}
fn as_raw_fd(&self) -> Option<RawFd> {
Some(self.fd.as_raw_fd())
}
fn tty_name(&self) -> Option<std::path::PathBuf> {
None
}
}
/// Signals a child by pid. Stands in for portable-pty's killer, whose handle
/// doesn't survive the exec. SIGHUP matches what portable-pty sends, so an
/// adopted pane dies exactly like a spawned one.
#[derive(Debug)]
struct PidKiller(u32);
impl ChildKiller for PidKiller {
fn kill(&mut self) -> std::io::Result<()> {
if unsafe { libc::kill(self.0 as i32, libc::SIGHUP) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(PidKiller(self.0))
}
}
impl Drop for EmbeddedTerm {
fn drop(&mut self) {
let _ = self.killer.kill();

140
src/ui.rs
View File

@@ -466,28 +466,57 @@ fn wrapped_height(lines: &[Line<'static>], width: u16) -> usize {
.line_count(width)
}
pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
/// `pane`/`pane_ui` are non-empty only after a hot reload: the `claude` child
/// is still running on the pty we inherited through the exec, so the pane is
/// re-adopted rather than respawned (`reload.rs`).
pub fn run(
app: SharedApp,
port: u16,
pane: Option<crate::term::PtyHandoff>,
pane_ui: crate::reload::PaneState,
reloaded: bool,
) -> anyhow::Result<()> {
// Keep the session list populated with this directory's on-disk history.
crate::sessions::spawn_scanner(app.clone());
// Refresh the `n` model picker from the live `claude` alias set.
crate::term::spawn_model_discovery(app.clone());
let mut terminal = ratatui::init();
// After a reload the screen still holds the *old* image's frame, and
// ratatui diffs the first draw against an empty buffer — so any cell the
// new frame leaves blank would keep the old glyph. Wipe it once.
if reloaded {
let _ = terminal.clear();
}
// Mouse capture: wheel events scroll the feed (regardless of pane focus).
// Trade-off: text selection in the outer terminal now needs shift held.
// Bracketed paste: a multiline paste arrives as one `Event::Paste` we hand
// to the child as a bracketed paste, instead of N keystrokes whose first
// newline would submit the prompt.
let _ = execute!(std::io::stdout(), EnableMouseCapture, EnableBracketedPaste);
// A reload hands the pane back as a bare fd + pid; failing to adopt it
// costs the pane, never the app, so the error is only reported.
let adopted = pane.and_then(|h| match crate::term::EmbeddedTerm::adopt(h) {
Ok(t) => Some(t),
Err(e) => {
app.lock().unwrap().status = format!("pane lost in reload: {e:#}");
None
}
});
let has_pane = adopted.is_some();
let mut eui = EmbedUi {
term: None,
visible: false,
claude_focused: false,
fullscreen: false,
visible: pane_ui.visible && has_pane,
claude_focused: pane_ui.focused && has_pane,
fullscreen: pane_ui.fullscreen && has_pane,
past_embeds: pane_ui.past_embeds.into_iter().collect(),
compact_inner: if pane_ui.compact_inner == 0 {
crate::term::DEFAULT_COMPACT_INNER
} else {
pane_ui.compact_inner
},
term: adopted,
port,
past_embeds: HashSet::new(),
force_resume: None,
cursor_shape: None,
compact_inner: crate::term::DEFAULT_COMPACT_INNER,
shrink_pending: None,
};
let res = event_loop(&mut terminal, app, &mut eui);
@@ -501,6 +530,82 @@ pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
res
}
/// Begin a hot reload (ctrl-r): ask the proxy to drain. `try_reload` does the
/// exec once it reports back.
///
/// No build happens here or anywhere else — the binary on disk is whatever you
/// last built, and pressing this says "run that one now".
fn start_reload(app: &SharedApp) {
use crate::reload::Status;
let mut a = app.lock().unwrap();
if matches!(a.reload, Status::Draining(_)) {
return;
}
a.reload = Status::Draining(Instant::now());
// Take the sender rather than drop it: the proxy reads a *dropped* channel
// as "never shut down", so dropping would signal nothing.
if let Some(tx) = a.drain_tx.take() {
let _ = tx.send(());
}
}
/// Finish a hot reload: once the proxy has drained, `execve` the binary on disk
/// into this process. Called every frame; a no-op unless a reload is running.
///
/// The drain is the important part. An exec destroys the tokio tasks relaying
/// in-flight responses, so the proxy is asked to stop accepting and finish what
/// it has first (`proxy::run`'s graceful shutdown). The *socket* stays open
/// throughout — `App::listener_fd` is a dup of it — so requests Claude Code
/// makes during the swap wait in the kernel backlog and are served by the new
/// image. Nothing is refused and nothing is truncated.
///
/// Returns normally only when the reload did **not** happen.
fn try_reload(app: &SharedApp, eui: &mut EmbedUi, terminal: &mut ratatui::DefaultTerminal) {
use crate::reload::Status;
let exe = {
let a = app.lock().unwrap();
let Status::Draining(since) = &a.reload else { return };
// The cap keeps one stuck upstream response from pinning the reload
// forever; past it that response is cut, exactly as it would be with
// no drain at all.
if !a.drained.load(std::sync::atomic::Ordering::SeqCst)
&& since.elapsed() < crate::reload::DRAIN_MAX
{
return;
}
a.exe.clone()
};
// The pane crosses over only while its child is alive. Note this borrows
// rather than takes: an exec runs no destructors, so `EmbeddedTerm::drop`
// never fires and never SIGHUPs the child — and if the exec *fails* we
// still own the pane, unchanged. Do not turn this into a `take()`.
let pane = eui.term.as_ref().filter(|t| !t.exited()).and_then(|t| t.handoff());
let pane_ui = crate::reload::PaneState {
visible: eui.visible,
focused: eui.claude_focused,
fullscreen: eui.fullscreen,
past_embeds: eui.past_embeds.iter().cloned().collect(),
compact_inner: eui.compact_inner,
};
// crossterm caches the pre-raw termios in a process global that the exec
// wipes. Without this the new image records *raw* as the original state and
// hands the user's shell back in raw mode on quit.
let _ = ratatui::crossterm::terminal::disable_raw_mode();
let mut a = app.lock().unwrap();
let e = crate::reload::exec_into(&exe, &a, pane, &pane_ui);
// Only reachable when the exec failed — most likely ctrl-r landed while the
// linker had the file half-written. Put the terminal back, keep serving,
// and say so; pressing ctrl-r again is safe.
let _ = ratatui::crossterm::terminal::enable_raw_mode();
let _ = terminal.clear();
a.reload = Status::Failed(format!("{e:#}"));
}
fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) {
if eui.visible {
eui.visible = false;
@@ -752,6 +857,9 @@ fn event_loop(
let mut applied_cursor: Option<crate::term::CursorShape> = None;
loop {
terminal.draw(|f| draw(f, &app, eui, &mut sel, &mut caches))?;
// A drained ctrl-r takes over here — this call does not return when
// it succeeds.
try_reload(&app, eui, terminal);
if eui.cursor_shape != applied_cursor {
applied_cursor = eui.cursor_shape;
let style = eui
@@ -871,6 +979,16 @@ fn event_loop(
eui.fullscreen = false;
continue;
}
// ctrl-r hot-reloads onto the binary now on disk — you rebuild
// outside, this swaps the running instance onto it. Global, like
// ctrl-q: it has to work while the pane holds focus, where plain
// keys belong to the child.
if ctrl && k.code == KeyCode::Char('r') {
if k.kind == KeyEventKind::Press {
start_reload(&app);
}
continue;
}
if ctrl && k.code == KeyCode::Char('f') && eui.focused() {
if k.kind == KeyEventKind::Press {
eui.fullscreen = !eui.fullscreen;
@@ -1550,8 +1668,14 @@ fn draw(
} else {
format!("A streams ({n_lanes}) · {keys}")
};
// A rebuild in flight outranks the proxy line: it is the only thing in
// the app the user is actively waiting on.
let status = match a.reload.note(a.in_flight()) {
Some(n) => format!("{n} · {}", a.status),
None => a.status.clone(),
};
f.render_widget(
Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()),
Paragraph::new(Line::from(format!(" {status} | {keys}")).dark_gray()),
footer,
);