use std::process::Stdio;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio_util::sync::CancellationToken;
use bamboo_infrastructure::process::hide_window_for_tokio_command;
use bamboo_plugin::manifest::{HealthCheckKind, ShutdownSignal};
use super::{ServiceRuntime, ServiceState, ServiceStatusSnapshot};
const UNIX_ENV_ALLOWLIST: &[&str] = &["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL"];
#[cfg(target_os = "windows")]
const WINDOWS_ENV_ALLOWLIST: &[&str] = &[
"PATH",
"SystemRoot",
"SystemDrive",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"TEMP",
"TMP",
];
async fn set_state(runtime: &Arc<ServiceRuntime>, state: ServiceState) {
*runtime.state.write().await = state;
}
async fn set_last_error(runtime: &Arc<ServiceRuntime>, message: impl Into<String>) {
*runtime.last_error.write().await = Some(message.into());
}
pub(super) async fn snapshot(runtime: &Arc<ServiceRuntime>) -> ServiceStatusSnapshot {
let state = *runtime.state.read().await;
let pid_raw = runtime.pid.load(Ordering::SeqCst);
ServiceStatusSnapshot {
id: runtime.config.id.clone(),
plugin_id: runtime.config.plugin_id.clone(),
state,
pid: if pid_raw == 0 { None } else { Some(pid_raw) },
restart_count: runtime.restart_count.load(Ordering::SeqCst),
last_error: runtime.last_error.read().await.clone(),
}
}
fn spawn_child(config: &super::ServiceRuntimeConfig) -> std::io::Result<Child> {
let mut cmd = Command::new(&config.command);
hide_window_for_tokio_command(&mut cmd);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.args(&config.args)
.env_clear();
#[cfg(not(target_os = "windows"))]
for key in UNIX_ENV_ALLOWLIST {
if let Ok(value) = std::env::var(key) {
cmd.env(key, value);
}
}
#[cfg(target_os = "windows")]
for key in WINDOWS_ENV_ALLOWLIST {
if let Ok(value) = std::env::var(key) {
cmd.env(key, value);
}
}
cmd.env("BAMBOO_PLUGIN_SERVICE_CONFIG", &config.user_config_path);
cmd.envs(&config.env);
if let Some(cwd) = &config.cwd {
cmd.current_dir(cwd);
}
cmd.spawn()
}
fn spawn_stdio_logger(service_id: &str, child: &mut Child) {
if let Some(stdout) = child.stdout.take() {
let id = service_id.to_string();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::info!(service_id = %id, "[service stdout] {line}");
}
});
}
if let Some(stderr) = child.stderr.take() {
let id = service_id.to_string();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!(service_id = %id, "[service stderr] {line}");
}
});
}
}
#[cfg(not(target_os = "windows"))]
async fn send_graceful_signal(pid: u32) {
let _ = Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()
.await;
}
#[cfg(target_os = "windows")]
async fn send_graceful_signal(_pid: u32) {}
async fn terminate_child(child: &mut Child, graceful: &bamboo_plugin::manifest::GracefulShutdown) {
let pid = child.id();
if graceful.signal == ShutdownSignal::Term {
if let Some(pid) = pid {
send_graceful_signal(pid).await;
}
let deadline = tokio::time::Instant::now() + Duration::from_millis(graceful.timeout_ms);
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => {
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(_) => break,
}
}
}
let _ = child.start_kill();
let _ = child.wait().await;
}
async fn probe_health(spec: &bamboo_plugin::manifest::HealthCheckSpec) -> Result<(), String> {
let timeout = Duration::from_millis(spec.timeout_ms.max(1));
match spec.kind {
HealthCheckKind::ProcessAlive => Ok(()),
HealthCheckKind::Tcp => {
let target = spec
.target
.as_deref()
.ok_or_else(|| "tcp health_check missing target".to_string())?;
match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(target)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(error)) => Err(format!("tcp health check to '{target}' failed: {error}")),
Err(_) => Err(format!("tcp health check to '{target}' timed out")),
}
}
HealthCheckKind::Http => {
let target = spec
.target
.as_deref()
.ok_or_else(|| "http health_check missing target".to_string())?;
let client = reqwest::Client::new();
match tokio::time::timeout(timeout, client.get(target).send()).await {
Ok(Ok(response)) if response.status().is_success() => Ok(()),
Ok(Ok(response)) => Err(format!(
"http health check to '{target}' returned status {}",
response.status()
)),
Ok(Err(error)) => Err(format!("http health check to '{target}' failed: {error}")),
Err(_) => Err(format!("http health check to '{target}' timed out")),
}
}
}
}
enum RunOutcome {
StoppedByRequest,
Exited(String),
Unhealthy(String),
}
async fn supervise_running_child(
runtime: &Arc<ServiceRuntime>,
child: &mut Child,
stop_token: &CancellationToken,
) -> RunOutcome {
let health = runtime.config.health_check.clone();
if matches!(health.kind, HealthCheckKind::ProcessAlive) {
return tokio::select! {
_ = stop_token.cancelled() => RunOutcome::StoppedByRequest,
result = child.wait() => RunOutcome::Exited(
result.map(|status| format!("process exited: {status}"))
.unwrap_or_else(|error| format!("failed to wait on child: {error}")),
),
};
}
let mut ticker = tokio::time::interval(Duration::from_millis(health.interval_ms.max(100)));
ticker.tick().await; loop {
tokio::select! {
_ = stop_token.cancelled() => return RunOutcome::StoppedByRequest,
result = child.wait() => {
return RunOutcome::Exited(
result.map(|status| format!("process exited: {status}"))
.unwrap_or_else(|error| format!("failed to wait on child: {error}")),
);
}
_ = ticker.tick() => {
match probe_health(&health).await {
Ok(()) => {
if *runtime.state.read().await == ServiceState::Degraded {
set_state(runtime, ServiceState::Running).await;
}
}
Err(reason) => {
set_state(runtime, ServiceState::Degraded).await;
return RunOutcome::Unhealthy(reason);
}
}
}
}
}
}
fn compute_backoff_ms(policy: &bamboo_domain::mcp_config::ReconnectConfig, attempt: u32) -> u64 {
let mut backoff = policy
.initial_backoff_ms
.max(1)
.min(policy.max_backoff_ms.max(1));
for _ in 1..attempt {
backoff = backoff.saturating_mul(2).min(policy.max_backoff_ms);
}
backoff
}
async fn maybe_wait_before_restart(
runtime: &Arc<ServiceRuntime>,
stop_token: &CancellationToken,
consecutive_attempts: &mut u32,
) -> bool {
if runtime.shutdown.load(Ordering::SeqCst) {
return false;
}
let policy = runtime.config.restart_policy.clone();
if !policy.enabled {
return false;
}
*consecutive_attempts += 1;
if policy.max_attempts > 0 && *consecutive_attempts > policy.max_attempts {
set_last_error(
runtime,
format!(
"max restart attempts ({}) reached for service '{}'",
policy.max_attempts, runtime.config.id
),
)
.await;
return false;
}
runtime.restart_count.fetch_add(1, Ordering::SeqCst);
let backoff_ms = compute_backoff_ms(&policy, *consecutive_attempts);
set_state(runtime, ServiceState::Restarting).await;
tokio::select! {
_ = stop_token.cancelled() => false,
_ = tokio::time::sleep(Duration::from_millis(backoff_ms)) => !runtime.shutdown.load(Ordering::SeqCst),
}
}
pub(super) async fn run_supervisor(runtime: Arc<ServiceRuntime>) {
let stop_token = runtime.stop_token.clone();
let mut consecutive_attempts: u32 = 0;
loop {
if runtime.shutdown.load(Ordering::SeqCst) {
set_state(&runtime, ServiceState::Stopped).await;
return;
}
set_state(&runtime, ServiceState::Starting).await;
let mut child = match spawn_child(&runtime.config) {
Ok(child) => child,
Err(error) => {
set_last_error(&runtime, format!("failed to spawn: {error}")).await;
set_state(&runtime, ServiceState::Crashed).await;
if !maybe_wait_before_restart(&runtime, &stop_token, &mut consecutive_attempts)
.await
{
set_state(&runtime, ServiceState::Stopped).await;
return;
}
continue;
}
};
runtime.pid.store(child.id().unwrap_or(0), Ordering::SeqCst);
spawn_stdio_logger(&runtime.config.id, &mut child);
set_state(&runtime, ServiceState::Running).await;
consecutive_attempts = 0;
let outcome = supervise_running_child(&runtime, &mut child, &stop_token).await;
runtime.pid.store(0, Ordering::SeqCst);
match outcome {
RunOutcome::StoppedByRequest => {
set_state(&runtime, ServiceState::Stopping).await;
terminate_child(&mut child, &runtime.config.graceful_shutdown).await;
set_state(&runtime, ServiceState::Stopped).await;
return;
}
RunOutcome::Exited(reason) => {
set_last_error(&runtime, reason).await;
set_state(&runtime, ServiceState::Crashed).await;
}
RunOutcome::Unhealthy(reason) => {
set_last_error(&runtime, reason).await;
terminate_child(&mut child, &runtime.config.graceful_shutdown).await;
set_state(&runtime, ServiceState::Crashed).await;
}
}
if runtime.shutdown.load(Ordering::SeqCst) {
set_state(&runtime, ServiceState::Stopped).await;
return;
}
if !maybe_wait_before_restart(&runtime, &stop_token, &mut consecutive_attempts).await {
set_state(&runtime, ServiceState::Stopped).await;
return;
}
}
}
#[cfg(test)]
mod backoff_tests {
use super::compute_backoff_ms;
use bamboo_domain::mcp_config::ReconnectConfig;
fn policy(initial: u64, max: u64) -> ReconnectConfig {
ReconnectConfig {
initial_backoff_ms: initial,
max_backoff_ms: max,
..ReconnectConfig::default()
}
}
#[test]
fn backoff_doubles_and_clamps_to_ceiling() {
let p = policy(100, 500);
assert_eq!(compute_backoff_ms(&p, 1), 100);
assert_eq!(compute_backoff_ms(&p, 2), 200);
assert_eq!(compute_backoff_ms(&p, 3), 400);
assert_eq!(compute_backoff_ms(&p, 4), 500);
assert_eq!(compute_backoff_ms(&p, 10), 500);
}
#[test]
fn backoff_first_attempt_is_clamped_when_initial_exceeds_max() {
let p = policy(10_000, 500);
assert_eq!(compute_backoff_ms(&p, 1), 500);
assert_eq!(compute_backoff_ms(&p, 2), 500);
}
}