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}