Skip to main content

bamboo_server/service_manager/
mod.rs

1//! `ServiceManager` — supervises long-running "service" plugins (issue #479,
2//! prereq for epic #477's standalone connectors distributed as plugins).
3//!
4//! Sibling of `connect::ConnectManager` / `schedule_app::ScheduleManager`:
5//! constructed once in `app_state::builder`, always alive, fully inert until
6//! `start_service` is called. Neither existing precedent fits a resident
7//! service process as-is:
8//!
9//! - `connect::ConnectManager` is too thin (no restart/health).
10//! - `bamboo_mcp::manager::McpServerManager` is JSON-RPC-coupled
11//!   (`ToolIndex`/QoS/circuit-breaker are meaningless for a service that
12//!   speaks no MCP protocol at all).
13//!
14//! This module instead reuses the SHAPE of two `bamboo-mcp` patterns without
15//! depending on that crate:
16//!
17//! - Spawn mechanics from `bamboo_mcp::transports::stdio` (`kill_on_drop`,
18//!   `hide_window_for_tokio_command`, piped stdout/stderr → log lines via a
19//!   dedicated reader task per stream — see [`lifecycle::spawn_stdio_logger`]).
20//! - The health-check-drives-restart pattern from
21//!   `bamboo_mcp::manager::lifecycle::start_health_check` /
22//!   `manager::reconnect::attempt_reconnection`, generalized to
23//!   [`bamboo_plugin::manifest::HealthCheckKind`]'s three kinds (process
24//!   liveness is just "has the child exited", so only `Tcp`/`Http` need an
25//!   actual polling task — see [`lifecycle::supervise_running_child`]) and to
26//!   `bamboo_plugin::manifest::ShutdownSignal`'s graceful-then-hard-kill
27//!   stop, mirroring `reconnect`'s exponential backoff.
28//!
29//! # Security: `env_clear()` before applying declared env
30//!
31//! Unlike `bamboo-mcp`'s stdio transport (which inherits bamboo-server's
32//! FULL process environment — deliberately left alone, see that crate's
33//! module docs), [`lifecycle::spawn_child`] calls `Command::env_clear()` and
34//! then applies only a minimal `PATH`/`HOME`(+platform-runtime-essential)
35//! allowlist before the manifest's declared `env` and
36//! `BAMBOO_PLUGIN_SERVICE_CONFIG`. A service is the highest-trust plugin
37//! artifact kind (a resident, unconstrained process) — see issue #479
38//! security §2 — so it must not silently see every secret bamboo-server's own
39//! environment happens to carry (API keys, tokens, etc).
40//!
41//! # `shutdown` vs `stop_token`
42//!
43//! Each [`ServiceRuntime`] carries a `shutdown: AtomicBool` (the issue's
44//! explicit requirement) — the single source of truth `lifecycle` consults
45//! after ANY wake to decide "was this an intentional stop" vs "should
46//! `restart_policy` fire" — plus a `tokio_util::sync::CancellationToken` used
47//! purely as the WAKE mechanism (interrupting an in-progress health-check
48//! wait or restart backoff sleep). A bare `tokio::sync::Notify` was
49//! considered and rejected: `notify_waiters` only wakes CURRENTLY-waiting
50//! tasks, so a stop request arriving before the supervisor reaches its
51//! `.notified().await` would be silently lost — `CancellationToken` has no
52//! such race (once cancelled, `cancelled()` resolves immediately regardless
53//! of call order).
54//!
55//! # Boot-time reconcile
56//!
57//! `app_state::builder` starts every ENABLED service from every plugin's
58//! `installed.json` row in the background (mirrors
59//! `app_state::init::init_mcp_manager`'s background MCP bootstrap) — a
60//! service that is "supposed to run" (declared, enabled, plugin installed)
61//! but has no live runtime (the previous `bamboo serve` process died) is
62//! started fresh. See `app_state::builder`'s `boot_reconcile_services`.
63
64mod lifecycle;
65#[cfg(test)]
66mod tests;
67
68use std::collections::HashMap;
69use std::path::PathBuf;
70use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
71use std::sync::Arc;
72
73use dashmap::DashMap;
74use serde::Serialize;
75use tokio::sync::RwLock;
76use tokio_util::sync::CancellationToken;
77
78use bamboo_domain::mcp_config::ReconnectConfig;
79use bamboo_plugin::manifest::{GracefulShutdown, HealthCheckSpec};
80
81/// Resolved, ready-to-spawn configuration for one service — the output of
82/// `ServiceManifestEntry::resolve` plus the owning plugin id and the
83/// per-service user config path (see the module docs on
84/// `BAMBOO_PLUGIN_SERVICE_CONFIG`). Built by
85/// `plugin_installer::ServerPluginInstaller` (the only place with both
86/// `AppState::app_data_dir` and a validated manifest).
87#[derive(Debug, Clone)]
88pub struct ServiceRuntimeConfig {
89    pub id: String,
90    pub plugin_id: String,
91    pub name: Option<String>,
92    pub command: PathBuf,
93    pub args: Vec<String>,
94    pub cwd: Option<PathBuf>,
95    pub env: HashMap<String, String>,
96    pub health_check: HealthCheckSpec,
97    pub restart_policy: ReconnectConfig,
98    pub graceful_shutdown: GracefulShutdown,
99    /// `<data_dir>/plugins/<plugin_id>/config.json`, passed to the child as
100    /// `BAMBOO_PLUGIN_SERVICE_CONFIG`. bamboo only ever creates the PARENT
101    /// directory (`plugin_installer::ServerPluginInstaller::resolve_service_config`)
102    /// — it never writes or deletes this file itself, so a user/service's own
103    /// config there survives an upgrade OR uninstall of the plugin bundle
104    /// (bundles are plaintext; connectors carry tokens that must not be
105    /// silently deleted when the bundle is — see issue #479 open question 2).
106    pub user_config_path: PathBuf,
107}
108
109/// Lifecycle state of one supervised service.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ServiceState {
113    Starting,
114    Running,
115    Degraded,
116    Crashed,
117    Restarting,
118    Stopping,
119    Stopped,
120}
121
122/// Point-in-time status of one service — what the plugin-list HTTP surface
123/// (`InstalledPluginView::service_status`) and `bamboo plugin` status
124/// commands read.
125#[derive(Debug, Clone, Serialize)]
126pub struct ServiceStatusSnapshot {
127    pub id: String,
128    pub plugin_id: String,
129    pub state: ServiceState,
130    pub pid: Option<u32>,
131    pub restart_count: u32,
132    pub last_error: Option<String>,
133}
134
135/// Errors [`ServiceManager`]'s public API can return. Deliberately tiny
136/// (unlike `bamboo_mcp::error::McpError`) — there is no protocol layer here
137/// to report errors from.
138#[derive(Debug, thiserror::Error)]
139pub enum ServiceManagerError {
140    #[error("service '{0}' is already running")]
141    AlreadyRunning(String),
142    #[error("service '{0}' is not running")]
143    NotRunning(String),
144}
145
146/// Per-service runtime state, shared between the public [`ServiceManager`]
147/// API and the supervisor task in [`lifecycle`].
148pub(crate) struct ServiceRuntime {
149    config: ServiceRuntimeConfig,
150    state: RwLock<ServiceState>,
151    /// `0` when no child is currently alive.
152    pid: AtomicU32,
153    /// Lifetime-cumulative count of restart attempts (exposed in status;
154    /// never resets). Distinct from `lifecycle`'s internal
155    /// consecutive-attempt counter, which DOES reset on every successful
156    /// `Running` transition and drives backoff/`max_attempts`.
157    restart_count: AtomicU32,
158    last_error: RwLock<Option<String>>,
159    /// Set by [`ServiceManager::stop_service`] BEFORE cancelling
160    /// `stop_token`. The supervisor consults this (not which `select!`
161    /// branch fired) after every wake to decide "intentional stop, don't
162    /// restart" vs "unexpected exit, `restart_policy` may apply". See the
163    /// module docs.
164    shutdown: AtomicBool,
165    stop_token: CancellationToken,
166    supervisor: RwLock<Option<tokio::task::JoinHandle<()>>>,
167}
168
169/// Owns every supervised service's runtime. Empty/inert until
170/// `start_service` is called — mirrors `McpServerManager`'s
171/// always-constructed-but-lazy lifecycle. Cheap to construct (`DashMap::new`,
172/// no I/O), so `AppState` builds exactly one and shares it via `Arc`.
173#[derive(Default)]
174pub struct ServiceManager {
175    runtimes: DashMap<String, Arc<ServiceRuntime>>,
176}
177
178impl ServiceManager {
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// Start supervising `config.id`. Spawns the child (or fails fast and
184    /// enters the restart-backoff loop immediately if the very first spawn
185    /// fails — matching `plugin_installer`'s "best-effort start, ownership
186    /// is still recorded" contract for MCP servers) and returns as soon as
187    /// the supervisor task is scheduled — does NOT wait for the child to
188    /// actually come up (the caller reads [`Self::status`] for that).
189    pub async fn start_service(
190        &self,
191        config: ServiceRuntimeConfig,
192    ) -> Result<(), ServiceManagerError> {
193        let id = config.id.clone();
194        let runtime = Arc::new(ServiceRuntime {
195            config,
196            state: RwLock::new(ServiceState::Starting),
197            pid: AtomicU32::new(0),
198            restart_count: AtomicU32::new(0),
199            last_error: RwLock::new(None),
200            shutdown: AtomicBool::new(false),
201            stop_token: CancellationToken::new(),
202            supervisor: RwLock::new(None),
203        });
204        // Atomic check-and-insert via the entry API: a plain
205        // contains_key→insert would let two racing callers (boot reconcile —
206        // spawned outside PLUGIN_OP_LOCK — vs a concurrent install of the
207        // same plugin) both pass the check, the second overwriting the
208        // first's runtime and orphaning its supervisor task + child process
209        // beyond stop_service's reach.
210        match self.runtimes.entry(id) {
211            dashmap::mapref::entry::Entry::Occupied(occupied) => {
212                return Err(ServiceManagerError::AlreadyRunning(occupied.key().clone()));
213            }
214            dashmap::mapref::entry::Entry::Vacant(vacant) => {
215                vacant.insert(runtime.clone());
216            }
217        }
218        let handle = tokio::spawn(lifecycle::run_supervisor(runtime.clone()));
219        *runtime.supervisor.write().await = Some(handle);
220        Ok(())
221    }
222
223    /// Stop `id`: marks the runtime `shutdown` (so the supervisor never
224    /// restarts it), cancels its stop token (waking it out of any
225    /// health-check wait / backoff sleep / `child.wait()`), performs the
226    /// graceful-signal → timeout → hard-kill sequence (see
227    /// [`bamboo_plugin::manifest::GracefulShutdown`]), and awaits the
228    /// supervisor task's exit before returning. Idempotent-adjacent: a
229    /// second call on an already-removed id returns
230    /// [`ServiceManagerError::NotRunning`] rather than panicking, so callers
231    /// (uninstall/upgrade drop-diff, rollback) can treat it as best-effort
232    /// exactly like `mcp_manager.stop_server`.
233    pub async fn stop_service(&self, id: &str) -> Result<(), ServiceManagerError> {
234        let Some((_, runtime)) = self.runtimes.remove(id) else {
235            return Err(ServiceManagerError::NotRunning(id.to_string()));
236        };
237        runtime.shutdown.store(true, Ordering::SeqCst);
238        runtime.stop_token.cancel();
239        let handle = runtime.supervisor.write().await.take();
240        if let Some(handle) = handle {
241            let _ = handle.await;
242        }
243        Ok(())
244    }
245
246    pub fn is_running(&self, id: &str) -> bool {
247        self.runtimes.contains_key(id)
248    }
249
250    pub async fn status(&self, id: &str) -> Option<ServiceStatusSnapshot> {
251        let runtime = self.runtimes.get(id)?.clone();
252        Some(lifecycle::snapshot(&runtime).await)
253    }
254
255    /// Snapshot of every currently-supervised service (regardless of which
256    /// plugin owns it) — the raw material `handlers::agent::plugin`'s list
257    /// handler groups back by `plugin_id` into
258    /// `InstalledPluginView::service_status`.
259    pub async fn list_status(&self) -> Vec<ServiceStatusSnapshot> {
260        let runtimes: Vec<Arc<ServiceRuntime>> = self
261            .runtimes
262            .iter()
263            .map(|entry| entry.value().clone())
264            .collect();
265        let mut out = Vec::with_capacity(runtimes.len());
266        for runtime in &runtimes {
267            out.push(lifecycle::snapshot(runtime).await);
268        }
269        out
270    }
271}