diff --git a/CLAUDE.md b/CLAUDE.md index 8a26184..3bcbd7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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: `), 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: `), 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 `` 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). diff --git a/Cargo.lock b/Cargo.lock index bd55481..f34e590 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "anyhow", "axum", "futures-util", + "libc", "portable-pty", "ratatui", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 60350dc..1451aff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 173e582..00fe6b4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 9191e80..e236b8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + /// 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>, + /// 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, + /// 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` /// 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, 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 ``. 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, } +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, @@ -1061,6 +1147,7 @@ pub struct Session { pub lane_of_agent: HashMap, /// 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, } +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 `` 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, diff --git a/src/main.rs b/src/main.rs index ac752d3..4ca6c4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::().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 { + 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::().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) +} diff --git a/src/proxy.rs b/src/proxy.rs index f341ff3..8784029 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -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(()) } diff --git a/src/reload.rs b/src/reload.rs new file mode 100644 index 0000000..df4c764 --- /dev/null +++ b/src/reload.rs @@ -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:` 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(v: &Instant, s: S) -> Result { + s.serialize_u64(v.elapsed().as_millis() as u64) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + 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`. +pub mod ms_ago_opt { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Instant; + + pub fn serialize(v: &Option, s: S) -> Result { + 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, D::Error> { + Ok(Option::::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 { + 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, + /// 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, + /// 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, + pub selected: usize, + pub scroll: usize, + pub follow: bool, + pub filters: Vec, + pub show_sessions: bool, + pub embed_session: Option, + pub embed_token: Option, +} + +/// 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, + 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 { + 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::(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 { + 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::() 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 { + 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::(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 { + // `#[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-` 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::().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` 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, + app: AppStateRef<'a>, + pane_ui: &'a PaneState, + generation: u32, +} + +#[derive(serde::Serialize)] +struct AppStateRef<'a> { + sessions: &'a [crate::app::Session], + selected_key: Option, + selected: usize, + scroll: usize, + follow: bool, + filters: &'a [bool], + show_sessions: bool, + embed_session: &'a Option, + embed_token: &'a Option, +} + +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, + 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 = 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"); + } +} diff --git a/src/term.rs b/src/term.rs index 60227b7..68c39e1 100644 --- a/src/term.rs +++ b/src/term.rs @@ -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, } /// 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 { + 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 { + 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 { + 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 { + 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, anyhow::Error> { + Ok(Box::new(self.dup()?)) + } + + fn take_writer(&self) -> Result, anyhow::Error> { + Ok(Box::new(self.dup()?)) + } + + fn process_group_leader(&self) -> Option { + match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } { + pid if pid > 0 => Some(pid), + _ => None, + } + } + + fn as_raw_fd(&self) -> Option { + Some(self.fd.as_raw_fd()) + } + + fn tty_name(&self) -> Option { + 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 { + Box::new(PidKiller(self.0)) + } +} + impl Drop for EmbeddedTerm { fn drop(&mut self) { let _ = self.killer.kill(); diff --git a/src/ui.rs b/src/ui.rs index 26984c2..70aa1cd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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, + 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 = 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, );