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

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:
shadowdaoandClaude Opus 5.5 committed 2026-11-02 15:57:12 -08:00
1 parent e223a3f64b
commit eba0d95780
9 files changed
+574 -10

No files matched your search

+10 -3
View File
@@ -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
+278
View File
@@ -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);
}
}
+11
View File
@@ -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.
{
+20 -5
View File
@@ -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));
}
+131
View File
@@ -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();
}
}
+2
View File
@@ -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();
});
});
+39
View File
@@ -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
View File
@@ -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