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 input;
65mod lifecycle;
66#[cfg(test)]
67mod tests;
68
69use std::collections::HashMap;
70use std::path::PathBuf;
71use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
72use std::sync::Arc;
73use std::sync::Mutex;
74
75use dashmap::DashMap;
76use serde::Serialize;
77use tokio::sync::RwLock;
78use tokio_util::sync::CancellationToken;
79
80use bamboo_domain::mcp_config::ReconnectConfig;
81use bamboo_plugin::manifest::{GracefulShutdown, HealthCheckSpec, ServiceInputProtocol};
82
83pub use input::{
84    ServiceInputHealth, ServiceInputSendError, ServiceInputSender, ServiceInputStatusSnapshot,
85    DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY, MAX_SERVICE_INPUT_LINE_BYTES,
86};
87
88/// Resolved, ready-to-spawn configuration for one service — the output of
89/// `ServiceManifestEntry::resolve` plus the owning plugin id and the
90/// per-service user config path (see the module docs on
91/// `BAMBOO_PLUGIN_SERVICE_CONFIG`). Built by
92/// `plugin_installer::ServerPluginInstaller` (the only place with both
93/// `AppState::app_data_dir` and a validated manifest).
94#[derive(Debug, Clone)]
95pub struct ServiceRuntimeConfig {
96    pub id: String,
97    pub plugin_id: String,
98    pub name: Option<String>,
99    pub command: PathBuf,
100    pub args: Vec<String>,
101    pub cwd: Option<PathBuf>,
102    pub env: HashMap<String, String>,
103    pub health_check: HealthCheckSpec,
104    pub restart_policy: ReconnectConfig,
105    pub graceful_shutdown: GracefulShutdown,
106    /// `None` preserves null stdin; `NdjsonV1` creates a generation-bound,
107    /// bounded writer only after this exact child has spawned successfully.
108    pub input_protocol: ServiceInputProtocol,
109    /// `<data_dir>/plugins/<plugin_id>/config.json`, passed to the child as
110    /// `BAMBOO_PLUGIN_SERVICE_CONFIG`. bamboo only ever creates the PARENT
111    /// directory (`plugin_installer::ServerPluginInstaller::resolve_service_config`)
112    /// — it never writes or deletes this file itself, so a user/service's own
113    /// config there survives an upgrade OR uninstall of the plugin bundle
114    /// (bundles are plaintext; connectors carry tokens that must not be
115    /// silently deleted when the bundle is — see issue #479 open question 2).
116    pub user_config_path: PathBuf,
117}
118
119/// Lifecycle state of one supervised service.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum ServiceState {
123    Starting,
124    Running,
125    Degraded,
126    Crashed,
127    Restarting,
128    Stopping,
129    Stopped,
130}
131
132/// Point-in-time status of one service — what the plugin-list HTTP surface
133/// (`InstalledPluginView::service_status`) and `bamboo plugin` status
134/// commands read.
135#[derive(Debug, Clone, Serialize)]
136pub struct ServiceStatusSnapshot {
137    pub id: String,
138    pub plugin_id: String,
139    pub state: ServiceState,
140    pub pid: Option<u32>,
141    pub restart_count: u32,
142    pub last_error: Option<String>,
143    /// Omitted for legacy/`none` services so their serialized status shape and
144    /// behavior remain unchanged.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub input: Option<ServiceInputStatusSnapshot>,
147}
148
149/// Errors [`ServiceManager`]'s public API can return. Deliberately tiny
150/// (unlike `bamboo_mcp::error::McpError`) — there is no protocol layer here
151/// to report errors from.
152#[derive(Debug, thiserror::Error)]
153pub enum ServiceManagerError {
154    #[error("service '{0}' is already running")]
155    AlreadyRunning(String),
156    #[error("service '{0}' is not running")]
157    NotRunning(String),
158}
159
160/// Per-service runtime state, shared between the public [`ServiceManager`]
161/// API and the supervisor task in [`lifecycle`].
162pub(crate) struct ServiceRuntime {
163    config: ServiceRuntimeConfig,
164    state: RwLock<ServiceState>,
165    /// `0` when no child is currently alive.
166    pid: AtomicU32,
167    /// Lifetime-cumulative count of restart attempts (exposed in status;
168    /// never resets). Distinct from `lifecycle`'s internal
169    /// consecutive-attempt counter, which DOES reset on every successful
170    /// `Running` transition and drives backoff/`max_attempts`.
171    restart_count: AtomicU32,
172    last_error: RwLock<Option<String>>,
173    /// Set by [`ServiceManager::stop_service`] BEFORE cancelling
174    /// `stop_token`. The supervisor consults this (not which `select!`
175    /// branch fired) after every wake to decide "intentional stop, don't
176    /// restart" vs "unexpected exit, `restart_policy` may apply". See the
177    /// module docs.
178    shutdown: AtomicBool,
179    stop_token: CancellationToken,
180    /// Synchronous because publication ordering is the invariant: the handle
181    /// is stored before `runtimes.insert`, and stop takes it after removal,
182    /// then awaits outside every map/lock guard. No async critical section is
183    /// needed for this single write/single take slot.
184    supervisor: Mutex<Option<tokio::task::JoinHandle<()>>>,
185    /// Present only for an explicitly declared `ndjson_v1` service. The slot
186    /// inside is rebound once per successful process spawn.
187    input: Option<input::ServiceInputRuntime>,
188}
189
190/// Owns every supervised service's runtime. Empty/inert until
191/// `start_service` is called — mirrors `McpServerManager`'s
192/// always-constructed-but-lazy lifecycle. Cheap to construct (`DashMap::new`,
193/// no I/O), so `AppState` builds exactly one and shares it via `Arc`.
194#[derive(Default)]
195pub struct ServiceManager {
196    runtimes: DashMap<String, Arc<ServiceRuntime>>,
197    /// Manager-lifetime allocator shared by every runtime. It deliberately
198    /// survives `stop_service` followed by a same-id `start_service`, so an
199    /// old opaque sender can never collide with a new binding via generation
200    /// reuse (ABA across upgrade/reinstall).
201    next_input_generation: Arc<AtomicU64>,
202}
203
204impl ServiceManager {
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    /// Start supervising `config.id`. Spawns the child (or fails fast and
210    /// enters the restart-backoff loop immediately if the very first spawn
211    /// fails — matching `plugin_installer`'s "best-effort start, ownership
212    /// is still recorded" contract for MCP servers) and returns as soon as
213    /// the supervisor task is scheduled — does NOT wait for the child to
214    /// actually come up (the caller reads [`Self::status`] for that).
215    pub async fn start_service(
216        &self,
217        config: ServiceRuntimeConfig,
218    ) -> Result<(), ServiceManagerError> {
219        self.start_service_inner(config, || async {}).await
220    }
221
222    async fn start_service_inner<F, Fut>(
223        &self,
224        config: ServiceRuntimeConfig,
225        after_publish: F,
226    ) -> Result<(), ServiceManagerError>
227    where
228        F: FnOnce() -> Fut,
229        Fut: std::future::Future<Output = ()>,
230    {
231        let id = config.id.clone();
232        let input = matches!(config.input_protocol, ServiceInputProtocol::NdjsonV1).then(|| {
233            input::ServiceInputRuntime::new(id.clone(), self.next_input_generation.clone())
234        });
235        let runtime = Arc::new(ServiceRuntime {
236            config,
237            state: RwLock::new(ServiceState::Starting),
238            pid: AtomicU32::new(0),
239            restart_count: AtomicU32::new(0),
240            last_error: RwLock::new(None),
241            shutdown: AtomicBool::new(false),
242            stop_token: CancellationToken::new(),
243            supervisor: Mutex::new(None),
244            input,
245        });
246        // Atomic check-and-insert via the entry API: a plain
247        // contains_key→insert would let two racing callers (for example,
248        // direct ServiceManager users or any future reconciliation path that
249        // does not share the plugin-operation boundary) both pass the check,
250        // the second overwriting the first's runtime and orphaning its
251        // supervisor task + child process beyond stop_service's reach. Boot
252        // and plugin mutations are now serialized separately, but the manager
253        // still enforces its own invariant at this public boundary.
254        match self.runtimes.entry(id) {
255            dashmap::mapref::entry::Entry::Occupied(occupied) => {
256                return Err(ServiceManagerError::AlreadyRunning(occupied.key().clone()));
257            }
258            dashmap::mapref::entry::Entry::Vacant(vacant) => {
259                // `tokio::spawn` may schedule immediately, but no stopper can
260                // discover this runtime until after its handle is synchronously
261                // stored. This prevents stop from removing a published runtime,
262                // seeing `None`, and returning while a detached child starts.
263                let handle = tokio::spawn(lifecycle::run_supervisor(runtime.clone()));
264                *runtime
265                    .supervisor
266                    .lock()
267                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(handle);
268                vacant.insert(runtime.clone());
269            }
270        }
271        after_publish().await;
272        Ok(())
273    }
274
275    #[cfg(test)]
276    pub(super) async fn start_service_with_publish_hook<F, Fut>(
277        &self,
278        config: ServiceRuntimeConfig,
279        after_publish: F,
280    ) -> Result<(), ServiceManagerError>
281    where
282        F: FnOnce() -> Fut,
283        Fut: std::future::Future<Output = ()>,
284    {
285        self.start_service_inner(config, after_publish).await
286    }
287
288    /// Stop `id`: marks the runtime `shutdown` (so the supervisor never
289    /// restarts it), cancels its stop token (waking it out of any
290    /// health-check wait / backoff sleep / `child.wait()`), performs the
291    /// graceful-signal → timeout → hard-kill sequence (see
292    /// [`bamboo_plugin::manifest::GracefulShutdown`]), and awaits the
293    /// supervisor task's exit before returning. Idempotent-adjacent: a
294    /// second call on an already-removed id returns
295    /// [`ServiceManagerError::NotRunning`] rather than panicking, so callers
296    /// (uninstall/upgrade drop-diff, rollback) can treat it as best-effort
297    /// exactly like `mcp_manager.stop_server`.
298    pub async fn stop_service(&self, id: &str) -> Result<(), ServiceManagerError> {
299        let Some((_, runtime)) = self.runtimes.remove(id) else {
300            return Err(ServiceManagerError::NotRunning(id.to_string()));
301        };
302        // Publish shutdown synchronously before awaiting the input slot. This
303        // prevents the supervisor from deciding to restart while stop is
304        // waiting behind a concurrent bind/status reconciliation.
305        runtime.shutdown.store(true, Ordering::SeqCst);
306        // Then revoke/cancel input before waking the supervisor. Upgrade/
307        // uninstall await this method before swapping/removing the verified
308        // binary, so no producer can enqueue after shutdown begins.
309        if let Some(input) = &runtime.input {
310            input.stop_active().await;
311        }
312        runtime.stop_token.cancel();
313        let handle = runtime
314            .supervisor
315            .lock()
316            .unwrap_or_else(std::sync::PoisonError::into_inner)
317            .take();
318        if let Some(handle) = handle {
319            let _ = handle.await;
320        }
321        Ok(())
322    }
323
324    pub fn is_running(&self, id: &str) -> bool {
325        self.runtimes.contains_key(id)
326    }
327
328    pub async fn status(&self, id: &str) -> Option<ServiceStatusSnapshot> {
329        let runtime = self.runtimes.get(id)?.clone();
330        Some(lifecycle::snapshot(&runtime).await)
331    }
332
333    /// Acquire the non-blocking sender for the currently live process
334    /// generation. Returns `None` for a legacy/no-input service, while it is
335    /// starting/restarting, or after its stdin writer has been unbound.
336    /// Existing handles deliberately do not follow a restart.
337    pub async fn input_sender(&self, id: &str) -> Option<ServiceInputSender> {
338        let runtime = self.runtimes.get(id)?.clone();
339        runtime.input.as_ref()?.sender().await
340    }
341
342    /// Snapshot of every currently-supervised service (regardless of which
343    /// plugin owns it) — the raw material `handlers::agent::plugin`'s list
344    /// handler groups back by `plugin_id` into
345    /// `InstalledPluginView::service_status`.
346    pub async fn list_status(&self) -> Vec<ServiceStatusSnapshot> {
347        let runtimes: Vec<Arc<ServiceRuntime>> = self
348            .runtimes
349            .iter()
350            .map(|entry| entry.value().clone())
351            .collect();
352        let mut out = Vec::with_capacity(runtimes.len());
353        for runtime in &runtimes {
354            out.push(lifecycle::snapshot(runtime).await);
355        }
356        out
357    }
358}