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}