Keep containers alive as root; notice containers that exit on their own
Secret Scan / scan (push) Successful in 7s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / test (pull_request) Successful in 4m45s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 3m16s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
Build App (Preview) / build-windows (pull_request) Failing after 6m38s
Build App (Preview) / prune-previews (pull_request) Skipped
Build Container / build-container (pull_request) Successful in 23m46s
Secret Scan / scan (push) Successful in 7s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / test (pull_request) Successful in 4m45s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 3m16s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
Build App (Preview) / build-windows (pull_request) Failing after 6m38s
Build App (Preview) / prune-previews (pull_request) Skipped
Build Container / build-container (pull_request) Successful in 23m46s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e223a3f64b
commit
eba0d95780
9 files changed
+574
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<Project> {
|
||||
// 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<Project> {
|
||||
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<String, Vec<String>> {
|
||||
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<ProjectsStore>,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
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::<String> {
|
||||
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<ProjectsStore>,
|
||||
cancel: watch::Receiver<bool>,
|
||||
) -> 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::<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<Project>) -> Self {
|
||||
Self { projects: Mutex::new(projects), file_path }
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||
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<Project> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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>("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?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
+35
-2
@@ -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
|
||||
Reference in new issue
Block a user