From eba0d957807b74fcac6d361fb285b42dd44ca3e2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 15:57:12 -0800 Subject: [PATCH] Keep containers alive as root; notice containers that exit on their own The entrypoint ended in `exec su -s /bin/bash claude -c "exec sleep infinity"`, so a project container's whole lifetime was a sleep owned by the unprivileged claude user. Claude Code runs as claude and routinely runs `pkill sleep`, `pkill -f`, `kill -9 -1` while testing, and any of those stopped the container: every terminal disconnected and the app kept showing Running. Entrypoint: stay resident as root and wait on a root-owned sleep run as `triple-c-keepalive infinity`, respawned if killed, with a TERM/INT/HUP trap so `docker stop` returns at once (550 ms, exit 0, vs 2.6 s / 143 before). Unprivileged kills now get EPERM; `sudo pkill sleep` is survived by the respawn. The drop to claude dated from the initial commit with no stated purpose; its only reader was the marketplace readiness probe, which now accepts either generation of entrypoint. App: container_watch.rs holds one Docker event subscription filtered daemon-side to container `die`. A die naming a Running project's container is confirmed with an inspect, then ProjectsStore::mark_exited flips it to Stopped as a compare-and-set that skips any project a command holds the project lock on. Each (re)subscription re-checks Running projects and asks for events since just before it. The frontend applies the new `project-status-changed` event and toasts why the sessions died. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +- app/src-tauri/src/container_watch.rs | 278 +++++++++++++++++++ app/src-tauri/src/lib.rs | 11 + app/src-tauri/src/marketplace/sync.rs | 25 +- app/src-tauri/src/storage/projects_store.rs | 131 +++++++++ app/src/App.tsx | 2 + app/src/hooks/useProjectStatusEvents.test.ts | 48 ++++ app/src/hooks/useProjectStatusEvents.ts | 39 +++ container/entrypoint.sh | 37 ++- 9 files changed, 574 insertions(+), 10 deletions(-) create mode 100644 app/src-tauri/src/container_watch.rs create mode 100644 app/src/hooks/useProjectStatusEvents.test.ts create mode 100644 app/src/hooks/useProjectStatusEvents.ts diff --git a/CLAUDE.md b/CLAUDE.md index 56f6cba..8c72fff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -313,7 +313,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li approach converges on the worst state: a 400 MB browser present with its libraries gone. - The layer sits immediately after Node (npx is its only prerequisite) and well above the shim `COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install. -- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` +- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then stays resident **as root** waiting on a root-owned + `triple-c-keepalive` sleep (respawned if killed; SIGTERM trap so `docker stop` is immediate). + **Never hand the container's lifetime to a process the `claude` user owns** — it used to end in + `exec su … claude -c "exec sleep infinity"`, and any `pkill sleep` / `kill -9 -1` a Claude + session ran killed the whole container. `container_watch.rs` (one Docker `die`-event + subscription) is what flips a project whose container exits on its own to Stopped and emits + `project-status-changed`; `useProjectStatusEvents` applies it - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations **`/home/claude` in the image is seed-only.** It is the mount point of the named volume @@ -672,8 +678,9 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults. now", the host builds the project's effective set (`global − disabled ∪ project`), then uploads `payload.tar` **and the app-embedded script `src/marketplace/sync.sh`** (`include_str!`, not a file in `container/`) to `~/.claude/triple-c/marketplace/incoming/` and runs it as `claude`, - once the entrypoint has finished (`pgrep -x -f 'su -s /bin/bash claude -c exec sleep - infinity'`). The script is re-uploaded on every sync rather than baked into the image, so every + once the entrypoint has finished (`READY_PROBE`: a root `triple-c-keepalive infinity` process + on current images, `su -s /bin/bash claude -c exec sleep infinity` on older ones — keep both). + The script is re-uploaded on every sync rather than baked into the image, so every existing project always gets the version that matches the running app — `container/` is never touched for this feature. The script only removes files and hook entries it recorded in `~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created diff --git a/app/src-tauri/src/container_watch.rs b/app/src-tauri/src/container_watch.rs new file mode 100644 index 0000000..cfb1508 --- /dev/null +++ b/app/src-tauri/src/container_watch.rs @@ -0,0 +1,278 @@ +//! Notices a project container that stopped without the app asking. +//! +//! Until this existed the app only ever learned a container's state from its +//! own commands and from `reconcile_project_statuses`, which the frontend runs +//! once, when Docker first answers. A container that died underneath a running +//! app — the entrypoint's keepalive killed, an OOM, `docker stop` from a shell, +//! a daemon restart — left its project at Running indefinitely: every terminal +//! disconnected, while the sidebar, Start/Stop and Project Home all still +//! described a live container. +//! +//! One subscription to Docker's event stream, filtered daemon-side to container +//! `die` events, so an idle daemon costs nothing and the app hears about a death +//! as it happens. No polling loop. Each `die` naming a project's container is +//! confirmed with one inspect before anything changes (an event can be +//! replayed, and a restart can reuse the id), and the flip itself is +//! [`ProjectsStore::mark_exited`]'s compare-and-set, which never touches a +//! project a command owns. The frontend hears it on [`PROJECT_STATUS_EVENT`]. +//! +//! The stream drops whenever the daemon does. Each (re)subscription asks for +//! events since just before it, and first re-checks every Running project once — +//! the only window that replay cannot cover is a daemon restart, which forgets +//! its event history, and that is exactly when containers go away. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use bollard::errors::Error as DockerError; +use bollard::models::{EventMessage, EventMessageTypeEnum}; +use bollard::system::EventsOptions; +use bollard::Docker; +use futures_util::StreamExt; +use tauri::{AppHandle, Emitter}; +use tokio::sync::watch; + +use crate::models::{Project, ProjectStatus}; +use crate::storage::projects_store::ProjectsStore; + +/// Emitted with the updated `Project` whenever this module changes a status. +pub const PROJECT_STATUS_EVENT: &str = "project-status-changed"; + +const RETRY_MIN: Duration = Duration::from_secs(5); +const RETRY_MAX: Duration = Duration::from_secs(60); + +/// The container id a Docker event reports as having died, if it is one. +pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> { + if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") { + return None; + } + msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty()) +} + +/// `true` only when Docker positively says the container is not running: it +/// inspects as stopped, or it no longer exists. Any other failure is "don't +/// know" — `docker::is_container_running` maps every error to `false`, which +/// is right for its callers and wrong here, where it would mark every project +/// Stopped the moment the daemon hiccups. +async fn has_exited(docker: &Docker, container_id: &str) -> bool { + match docker.inspect_container(container_id, None).await { + Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false), + Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true, + Err(_) => false, + } +} + +fn project_busy(project_id: &str) -> bool { + crate::project_lock::held(project_id).is_some() +} + +/// Confirm `container_id` really stopped and, if a project was Running on it, +/// flip that project. Returns what changed. +async fn settle(docker: &Docker, store: &ProjectsStore, container_id: &str) -> Option { + // The cheap filter first: most containers that die are not ours (probe and + // scratch containers, other software on the daemon), and those cost no + // inspect at all. + let ours = store.list().into_iter().any(|p| { + p.status == ProjectStatus::Running + && p.container_id + .as_deref() + .is_some_and(|c| c.starts_with(container_id) || container_id.starts_with(c)) + }); + if !ours || !has_exited(docker, container_id).await { + return None; + } + store.mark_exited(container_id, project_busy) +} + +/// Re-check every Running project once. Used on each (re)subscription. +async fn sweep(docker: &Docker, store: &ProjectsStore) -> Vec { + let mut changed = Vec::new(); + for p in store.list() { + if p.status != ProjectStatus::Running { + continue; + } + let Some(cid) = p.container_id.as_deref() else { continue }; + if let Some(c) = settle(docker, store, cid).await { + changed.push(c); + } + } + changed +} + +fn die_filter() -> HashMap> { + HashMap::from([ + ("type".to_string(), vec!["container".to_string()]), + ("event".to_string(), vec!["die".to_string()]), + ]) +} + +/// Watch until `cancel` flips, calling `on_change` for every project whose +/// container is found to have stopped on its own. Separate from [`spawn`] so a +/// test can drive it without a Tauri app. +pub(crate) async fn run( + store: Arc, + mut cancel: watch::Receiver, + on_change: impl Fn(&Project), +) { + let mut retry = RETRY_MIN; + loop { + if *cancel.borrow() { + return; + } + let docker = match crate::docker::get_docker() { + Ok(d) if d.ping().await.is_ok() => Some(d), + _ => None, + }; + if let Some(docker) = docker { + retry = RETRY_MIN; + // A second's overlap: the daemon replays from `since`, so a death + // during the sweep below is still delivered. Duplicates are + // harmless — `settle` confirms with an inspect every time. + let since = (chrono::Utc::now().timestamp() - 1).to_string(); + for p in sweep(&docker, &store).await { + on_change(&p); + } + let mut events = docker.events(Some(EventsOptions:: { + since: Some(since), + until: None, + filters: die_filter(), + })); + loop { + tokio::select! { + _ = cancel.changed() => return, + next = events.next() => match next { + Some(Ok(msg)) => { + if let Some(cid) = died_container(&msg) { + if let Some(p) = settle(&docker, &store, cid).await { + on_change(&p); + } + } + } + Some(Err(e)) => { + log::info!("Container watch: event stream ended ({e}) — reconnecting"); + break; + } + None => { + log::info!("Container watch: event stream closed — reconnecting"); + break; + } + }, + } + } + } + tokio::select! { + _ = cancel.changed() => return, + _ = tokio::time::sleep(retry) => {} + } + retry = (retry * 2).min(RETRY_MAX); + } +} + +/// Start the watcher for the life of the app. +pub fn spawn( + app: AppHandle, + store: Arc, + cancel: watch::Receiver, +) -> tauri::async_runtime::JoinHandle<()> { + tauri::async_runtime::spawn(async move { + run(store, cancel, |p| { + log::warn!( + "Project '{}' ({}): its container stopped without the app asking — marking Stopped", + p.name, + p.id + ); + let _ = app.emit(PROJECT_STATUS_EVENT, p); + }) + .await; + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bollard::models::EventActor; + + fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage { + EventMessage { + typ: Some(typ), + action: Some(action.to_string()), + actor: Some(EventActor { id: id.map(str::to_string), attributes: None }), + ..Default::default() + } + } + + #[test] + fn only_a_container_die_names_a_container() { + let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc")); + assert_eq!(died_container(&die), Some("abc")); + + for (typ, action, id) in [ + (EventMessageTypeEnum::CONTAINER, "stop", Some("abc")), + (EventMessageTypeEnum::CONTAINER, "start", Some("abc")), + (EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")), + (EventMessageTypeEnum::NETWORK, "die", Some("abc")), + (EventMessageTypeEnum::CONTAINER, "die", None), + (EventMessageTypeEnum::CONTAINER, "die", Some("")), + ] { + assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}"); + } + } + + #[test] + fn the_subscription_is_filtered_to_container_deaths_daemon_side() { + let f = die_filter(); + assert_eq!(f.get("type"), Some(&vec!["container".to_string()])); + assert_eq!(f.get("event"), Some(&vec!["die".to_string()])); + assert_eq!(f.len(), 2); + } + + /// End to end against a real daemon: a Running project whose container is + /// killed from outside the app is flipped to Stopped and reported, and one + /// whose container is still up is not. + #[tokio::test] + #[ignore = "needs a Docker daemon; creates and removes throwaway containers"] + async fn a_container_killed_outside_the_app_flips_its_project_to_stopped() { + use std::process::Command; + fn docker_cli(args: &[&str]) -> String { + let out = Command::new("docker").args(args).output().expect("docker cli"); + assert!(out.status.success(), "docker {:?}: {}", args, String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + let tag = uuid::Uuid::new_v4().simple().to_string(); + let doomed = docker_cli(&["run", "-d", "--name", &format!("tc-watch-a-{tag}"), "alpine:3", "sleep", "300"]); + let alive = docker_cli(&["run", "-d", "--name", &format!("tc-watch-b-{tag}"), "alpine:3", "sleep", "300"]); + + let dir = std::env::temp_dir().join(format!("triple-c-watch-{tag}")); + std::fs::create_dir_all(&dir).unwrap(); + let mut a = Project::new("a".to_string(), Vec::new()); + a.container_id = Some(doomed.clone()); + a.status = ProjectStatus::Running; + let mut b = Project::new("b".to_string(), Vec::new()); + b.container_id = Some(alive.clone()); + b.status = ProjectStatus::Running; + let (a_id, b_id) = (a.id.clone(), b.id.clone()); + let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), vec![a, b])); + + let (cancel_tx, cancel_rx) = watch::channel(false); + let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::(); + let watcher = tokio::spawn(run(store.clone(), cancel_rx, move |p| { + let _ = seen_tx.send(p.id.clone()); + })); + // Let it subscribe, then kill from "outside". + tokio::time::sleep(Duration::from_secs(2)).await; + docker_cli(&["kill", &doomed]); + + let seen = tokio::time::timeout(Duration::from_secs(15), seen_rx.recv()).await; + let _ = cancel_tx.send(true); + let _ = watcher.await; + let a_status = store.get(&a_id).unwrap().status; + let b_status = store.get(&b_id).unwrap().status; + docker_cli(&["rm", "-f", &doomed, &alive]); + std::fs::remove_dir_all(&dir).ok(); + + assert_eq!(seen.expect("reported within 15s"), Some(a_id)); + assert_eq!(a_status, ProjectStatus::Stopped); + assert_eq!(b_status, ProjectStatus::Running); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 70f905f..08a2bba 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod browser_view; #[cfg(test)] mod command_census; mod commands; +mod container_watch; mod docker; pub mod file_viewer; mod install_helper; @@ -307,6 +308,16 @@ pub fn run() { .await; }); + // Notice a project container that stops without the app asking + // (its keepalive killed, an OOM, a `docker stop` from a shell) and + // flip the project to Stopped. One daemon-side-filtered event + // subscription for the app's lifetime — see `container_watch`. + lifecycle_setup.track(container_watch::spawn( + app.handle().clone(), + projects_store_setup.clone(), + lifecycle_setup.cancellation(), + )); + // Marketplaces: refresh each once at startup, in the background. // Failures are logged, not toasted — the Marketplace tab shows them. { diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs index f2380f7..4221533 100644 --- a/app/src-tauri/src/marketplace/sync.rs +++ b/app/src-tauri/src/marketplace/sync.rs @@ -14,10 +14,17 @@ pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incomi /// app version reaches existing containers without an image migration. pub const SYNC_SCRIPT: &str = include_str!("sync.sh"); -/// True once the entrypoint has finished: its last step execs this exact -/// command line. Before that it may still be merging `settings.json` or running -/// `claude update`, both of which the sync would race. -const READY_PROBE: &str = "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"; +/// True once the entrypoint has finished: its last step starts a process with +/// one of these exact command lines. Before that it may still be merging +/// `settings.json` or running `claude update`, both of which the sync would race. +/// +/// Two, because there are two generations of entrypoint in the field. Current +/// images keep the container alive with a root-owned `triple-c-keepalive` +/// sleep (so a `pkill sleep` run as `claude` cannot stop the container — see +/// the end of `container/entrypoint.sh`); older ones `exec su … claude -c exec +/// sleep infinity`, and a project keeps its base image until it is migrated. +const READY_PROBE: &str = "pgrep -u root -x -f 'triple-c-keepalive infinity' >/dev/null \ + || pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"; const READY_TIMEOUT: Duration = Duration::from_secs(180); const READY_POLL: Duration = Duration::from_secs(2); @@ -196,14 +203,22 @@ mod tests { #[test] fn readiness_probes_the_entrypoints_final_exec() { + // Both generations of entrypoint: a project keeps the base image it + // was built from until it migrates, so the old `su … sleep` ending is + // the common case for years and must still read as ready. assert_eq!( ready_probe_cmd(), vec![ "sh", "-c", - "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null" + "pgrep -u root -x -f 'triple-c-keepalive infinity' >/dev/null \ + || pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null" ] ); + // The marker name is shared with the entrypoint; a rename on one side + // only would leave every new container "not ready" for 180 s. + let entrypoint = include_str!("../../../../container/entrypoint.sh"); + assert!(entrypoint.contains("exec -a triple-c-keepalive sleep infinity")); assert_eq!(READY_POLL, Duration::from_secs(2)); assert_eq!(READY_TIMEOUT, Duration::from_secs(180)); } diff --git a/app/src-tauri/src/storage/projects_store.rs b/app/src-tauri/src/storage/projects_store.rs index 6a261c5..86dd83c 100644 --- a/app/src-tauri/src/storage/projects_store.rs +++ b/app/src-tauri/src/storage/projects_store.rs @@ -161,6 +161,12 @@ impl ProjectsStore { Ok(store) } + /// A store over a file the test owns (`new()` insists on the real data dir). + #[cfg(test)] + pub(crate) fn over_file_for_test(file_path: PathBuf, projects: Vec) -> Self { + Self { projects: Mutex::new(projects), file_path } + } + fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { self.projects.lock().unwrap_or_else(|e| e.into_inner()) } @@ -237,6 +243,47 @@ impl ProjectsStore { Ok(()) } + /// A container stopped without the app asking: flip the project that is + /// `Running` on it to `Stopped` and return the updated record, or `None` + /// when there is nothing to flip. + /// + /// Compare-and-set under the store lock rather than a read followed by + /// `update_status`, so it cannot land on top of a command that got there + /// first: only a project that is *still* `Running` on *this* container is + /// touched, and never one `is_busy` says a command owns (the project lock + /// — Stop, Start, Reset and migration all hold it across their own + /// container's death and write the status they mean themselves). A start + /// that takes the project lock just after this check still blocks on the + /// store lock before it writes `Starting`, so the two serialise. + /// + /// `container_id` may be the short form; the store holds the full id. + pub fn mark_exited(&self, container_id: &str, is_busy: impl Fn(&str) -> bool) -> Option { + if container_id.len() < 12 { + return None; + } + let mut projects = self.lock(); + let p = projects.iter_mut().find(|p| { + p.status == crate::models::ProjectStatus::Running + && p.container_id + .as_deref() + .filter(|stored| stored.len() >= 12) + .is_some_and(|stored| stored.starts_with(container_id) || container_id.starts_with(stored)) + })?; + if is_busy(&p.id) { + return None; + } + p.status = crate::models::ProjectStatus::Stopped; + p.updated_at = chrono::Utc::now().to_rfc3339(); + let changed = p.clone(); + if let Err(e) = self.save(&projects) { + // The in-memory record is what the UI is told about and what every + // later command reads, so it stays flipped; the next save of any + // kind writes it out. + log::warn!("Could not persist Stopped for project {}: {}", changed.id, e); + } + Some(changed) + } + pub fn update_status(&self, id: &str, status: crate::models::ProjectStatus) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == id) { @@ -604,4 +651,88 @@ mod tests { fs::remove_dir_all(&dir).ok(); } + + // ── mark_exited: a container that died on its own ──────────────────── + + /// A full-length id, as Docker's events and the store both carry. + const C0FFEE: &str = "c0ffee0000000000000000000000000000000000000000000000000000000000"; + + fn running_on(container_id: &str) -> Project { + let mut p = Project::new("p".to_string(), Vec::new()); + p.container_id = Some(container_id.to_string()); + p.status = crate::models::ProjectStatus::Running; + p + } + + #[test] + fn a_running_project_whose_container_exited_is_marked_stopped_and_saved() { + let dir = temp_dir("exited"); + let p = running_on(C0FFEE); + let id = p.id.clone(); + let store = store_over(&dir, vec![p]); + + let changed = store.mark_exited(C0FFEE, |_| false).expect("flipped"); + + assert_eq!(changed.id, id); + assert_eq!(changed.status, crate::models::ProjectStatus::Stopped); + assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Stopped); + let on_disk = fs::read_to_string(dir.join("projects.json")).unwrap(); + assert!(on_disk.contains("\"stopped\""), "the flip must be persisted: {on_disk}"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn mark_exited_leaves_a_project_alone_while_a_command_owns_it() { + // Stop, Start-with-recreate, Reset and migration all hold the project + // lock across their own container's death; the command writes the + // status it means, and the watcher must not race it. + let dir = temp_dir("busy"); + let p = running_on(C0FFEE); + let id = p.id.clone(); + let store = store_over(&dir, vec![p]); + + assert!(store.mark_exited(C0FFEE, |pid| pid == id).is_none()); + assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Running); + // ...and it was only the claim that held it back. + assert!(store.mark_exited(C0FFEE, |_| false).is_some()); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn mark_exited_only_touches_running_projects_on_that_container() { + let dir = temp_dir("other"); + let mut starting = running_on(C0FFEE); + starting.status = crate::models::ProjectStatus::Starting; + let other = running_on(&"b".repeat(64)); + let (starting_id, other_id) = (starting.id.clone(), other.id.clone()); + let store = store_over(&dir, vec![starting, other]); + + assert!(store.mark_exited(C0FFEE, |_| false).is_none(), "Starting is a command's"); + assert!(store.mark_exited(&"d".repeat(64), |_| false).is_none(), "nobody's container"); + assert!(store.mark_exited("", |_| false).is_none()); + // A blank stored id is a prefix of everything; it must match nothing. + let dir2 = temp_dir("blank"); + let blank = running_on(""); + let store2 = store_over(&dir2, vec![blank]); + assert!(store2.mark_exited(&"f".repeat(64), |_| false).is_none()); + fs::remove_dir_all(&dir2).ok(); + assert_eq!( + store.get(&starting_id).unwrap().status, + crate::models::ProjectStatus::Starting + ); + assert_eq!(store.get(&other_id).unwrap().status, crate::models::ProjectStatus::Running); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn mark_exited_matches_a_short_id_against_the_stored_full_one() { + let dir = temp_dir("short"); + let full = "a".repeat(12) + &"b".repeat(52); + let p = running_on(&full); + let store = store_over(&dir, vec![p]); + + assert!(store.mark_exited("aaaa", |_| false).is_none(), "too short to be an id"); + assert!(store.mark_exited(&full[..12], |_| false).is_some()); + fs::remove_dir_all(&dir).ok(); + } } diff --git a/app/src/App.tsx b/app/src/App.tsx index 23f64f1..8454652 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -22,6 +22,7 @@ import { useSTT } from "./hooks/useSTT"; import { useContainerProgress } from "./hooks/useContainerProgress"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useMarketplaceSyncToasts } from "./hooks/useMarketplace"; +import { useProjectStatusEvents } from "./hooks/useProjectStatusEvents"; import MarketplaceView from "./components/marketplace/MarketplaceView"; import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState"; import { reconcileProjectStatuses } from "./lib/tauri-commands"; @@ -75,6 +76,7 @@ export default function App() { useContainerProgress(); useKeyboardShortcuts(); useMarketplaceSyncToasts(); + useProjectStatusEvents(); // Initialize on mount useEffect(() => { diff --git a/app/src/hooks/useProjectStatusEvents.test.ts b/app/src/hooks/useProjectStatusEvents.test.ts new file mode 100644 index 0000000..b3e2c53 --- /dev/null +++ b/app/src/hooks/useProjectStatusEvents.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useAppState } from "../store/appState"; +import type { Project } from "../lib/types"; + +let handler: ((e: { payload: unknown }) => void) | null = null; +const unlisten = vi.fn(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => { + if (name === "project-status-changed") handler = cb; + return unlisten; + }), +})); + +import { useProjectStatusEvents } from "./useProjectStatusEvents"; + +const project = (over: Partial): Project => + ({ id: "p1", name: "demo", status: "running", container_id: "c1", ...over }) as Project; + +describe("useProjectStatusEvents", () => { + beforeEach(() => { + handler = null; + unlisten.mockClear(); + useAppState.setState({ projects: [project({}), project({ id: "p2", name: "other" })], toasts: [] }); + }); + + it("replaces the project the backend reports, so a dead container stops reading Running", async () => { + renderHook(() => useProjectStatusEvents()); + await vi.waitFor(() => expect(handler).not.toBeNull()); + + handler!({ payload: project({ status: "stopped" }) }); + + const { projects, toasts } = useAppState.getState(); + expect(projects.find((p) => p.id === "p1")?.status).toBe("stopped"); + expect(projects.find((p) => p.id === "p2")?.status).toBe("running"); + // The terminals just went dead with no explanation; say why. + expect(toasts).toHaveLength(1); + expect(toasts[0].message).toContain("demo"); + expect(toasts[0].dedupeKey).toBe("container-exited-p1"); + }); + + it("stops listening on unmount", async () => { + const { unmount } = renderHook(() => useProjectStatusEvents()); + await vi.waitFor(() => expect(handler).not.toBeNull()); + unmount(); + expect(unlisten).toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useProjectStatusEvents.ts b/app/src/hooks/useProjectStatusEvents.ts new file mode 100644 index 0000000..5483c8b --- /dev/null +++ b/app/src/hooks/useProjectStatusEvents.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useAppState } from "../store/appState"; +import type { Project } from "../lib/types"; + +/** + * App-wide: apply status changes the backend makes on its own. Today that is + * `container_watch.rs` marking a project Stopped when its container exited + * without the app asking — before it, the project kept reading Running, with + * every terminal dead, until the app was restarted. + */ +export function useProjectStatusEvents() { + useEffect(() => { + let cancelled = false; + let unlisten: UnlistenFn | null = null; + void listen("project-status-changed", (event) => { + const project = event.payload; + const state = useAppState.getState(); + state.updateProjectInList(project); + if (project.status === "stopped") { + state.pushToast({ + kind: "error", + message: `The container for “${project.name}” stopped`, + detail: + "It exited without Triple-C stopping it — open sessions were disconnected. " + + "Start the project again to reconnect.", + dedupeKey: `container-exited-${project.id}`, + }); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); +} diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 41e4438..e1fd83c 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -736,6 +736,39 @@ timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/ho && echo "entrypoint: Claude Code is up to date" \ || echo "entrypoint: warning — Claude Code update skipped or failed (continuing)" -# ── Stay alive as claude ───────────────────────────────────────────────────── +# ── Stay alive, as root ────────────────────────────────────────────────────── +# The container lives exactly as long as this script: docker-init is PID 1 and +# exits when its one child does. So what keeps it alive must be something the +# claude user cannot kill. This used to end in +# exec su -s /bin/bash claude -c "exec sleep infinity" +# which hung the container's whole lifetime on a `sleep` owned by claude — and +# Claude Code runs as claude and routinely runs `pkill sleep`, `pkill -f …`, +# `killall` or `kill -9 -1` while testing something. Any of those stopped the +# container: every terminal disconnected and the app still said Running. +# Dropping to claude there bought nothing (it dates from the initial commit, +# with no stated reason, and nothing reads that process's owner); its one +# reader is the app's marketplace readiness probe, which now also matches the +# marker below (app/src-tauri/src/marketplace/sync.rs, READY_PROBE). +# +# * Unprivileged kills get EPERM on both this script and its sleep. Even +# `sudo pkill sleep` only kills the child, which the loop replaces; it takes +# a root kill of this script itself. +# * `docker stop`: docker-init forwards SIGTERM here, and bash runs the trap +# as soon as `wait` returns, so the container stops at once rather than +# sitting out Docker's grace period and being SIGKILLed. +# * The sleep runs as `triple-c-keepalive infinity` (exec -a), a root-owned +# command line nothing else has, and it exists only once every step above +# has finished — that is the readiness marker. Keep the name in step with +# READY_PROBE. echo "Triple-C container ready." -exec su -s /bin/bash claude -c "exec sleep infinity" +keepalive_pid="" +stop_keepalive() { + [ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null + exit 0 +} +trap stop_keepalive TERM INT HUP +while :; do + (exec -a triple-c-keepalive sleep infinity) & + keepalive_pid=$! + wait "$keepalive_pid" +done