Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gh \
bubblewrap \
openssh-server \
# tini — minimal init that reaps orphaned processes (see ENTRYPOINT below)
tini \
# Chrome runtime dependencies — required whether Chrome is system-installed
# or downloaded by the built-in fetcher. The fetcher provides the browser
# binary; these are the shared libraries it links against.
Expand Down Expand Up @@ -112,5 +114,10 @@ EXPOSE 19898 18789 9090
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:19898/api/health || exit 1

ENTRYPOINT ["docker-entrypoint.sh"]
# tini as PID 1 so orphaned grandchildren are always reaped, even before
# spacebot's own reaper starts or if it ever stops. Spacebot reaps orphans
# itself (see src/process/reaper.rs); tini is the belt-and-braces layer that
# also covers the window during startup and shutdown. `-g` forwards signals to
# the whole process group so shutdown stays clean.
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "docker-entrypoint.sh"]
CMD ["spacebot", "start", "--foreground"]
30 changes: 30 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ There is one published image: `ghcr.io/spacedriveapp/spacebot`.
- Browser support is built in: Chromium is downloaded on first browser-tool use and cached under `/data`
- Legacy `-slim` / `-full` tags are deprecated

## Process Model

Spacebot runs as PID 1 in the container, which makes it the namespace's init: it
inherits every orphaned process, not just the ones it spawned. Shell commands
routinely leave grandchildren behind (`sh -c "cargo build"` exits, its
`cargo`/`node`/build-script descendants outlive it), and an init that never
reaps turns each one into a zombie holding a PID for the life of the container.

Two layers prevent that:

- **`tini` as PID 1** (`ENTRYPOINT`) — a real init that reaps anything
re-parented to it, including processes spawned before spacebot starts or after
it stops.
- **Spacebot's own reaper** (`src/process/reaper.rs`) — a `SIGCHLD`-driven sweep
that runs when spacebot *is* PID 1 (bare `docker run` without `--init`, or any
deployment that bypasses the entrypoint). It enumerates children from `/proc`
and reaps each one that no spawn site has claimed, so a child Tokio is waiting
on always keeps its exit status.

Check the current state without `docker exec`:

```bash
curl -s http://localhost:19898/api/status | jq '{zombie_processes, reaped_orphans}'
```

`zombie_processes` should stay near zero. A climbing value means orphans are not
being collected and the PID table will eventually fill, at which point no new
process can spawn — worker launches fail for a reason that looks nothing like
the cause.

## Data Volume

All persistent data lives at `/data` inside the container. Mount a volume here.
Expand Down
10 changes: 10 additions & 0 deletions src/api/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ pub(super) struct StatusResponse {
version: &'static str,
pid: u32,
uptime_seconds: u64,
/// Processes in the zombie state. Should stay near zero; a climbing value
/// means orphans are not being reaped and the PID table will fill up.
/// Absent where `/proc` is unavailable.
#[serde(skip_serializing_if = "Option::is_none")]
zombie_processes: Option<usize>,
/// Orphans collected by the built-in reaper since startup. Zero when a real
/// init (systemd, `docker run --init`) owns the reaping instead.
reaped_orphans: u64,
}

#[utoipa::path(
Expand Down Expand Up @@ -91,6 +99,8 @@ pub(super) async fn status(State(state): State<Arc<ApiState>>) -> Json<StatusRes
version: env!("CARGO_PKG_VERSION"),
pid: std::process::id(),
uptime_seconds: uptime.as_secs(),
zombie_processes: crate::process::reaper::zombie_count(),
reaped_orphans: crate::process::reaper::reaped_count(),
})
}

Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod messaging;
pub mod notifications;
pub mod openai_auth;
pub mod opencode;
pub mod process;
pub mod projects;
pub mod prompts;
pub mod registry;
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,12 @@ async fn run(
api_state.set_secrets_store(store.clone());
}

// Reap orphaned processes when running as PID 1 (containers). Shell
// commands leave grandchildren behind that are re-parented to this process;
// without an init to collect them they accumulate as zombies until the PID
// table is exhausted. No-op when a real init is present.
spacebot::process::reaper::spawn();

// Start background update checker
spacebot::update::spawn_update_checker(api_state.update_status.clone());

Expand Down
7 changes: 7 additions & 0 deletions src/process.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Process lifecycle concerns for the spacebot daemon.
//!
//! Spacebot spawns shell commands, coding agents, and build tools, and in a
//! container it is also PID 1. This module holds what that role requires —
//! today, reaping the orphans PID 1 inherits (see [`reaper`]).

pub mod reaper;
Loading