use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::process::Child;
use tokio::sync::{RwLock, watch};
use super::{EmbedderClient, StdioEmbedderClient, spawn_retry};
#[path = "supervisor_config.rs"]
mod supervisor_config;
pub use supervisor_config::{
DEFAULT_CUDA_SIDECAR_BATCH_CAP, SupervisorConfig, cuda_sidecar_batch_cap, sidecar_batch_size,
};
#[path = "supervisor_locate.rs"]
mod supervisor_locate;
pub use supervisor_locate::locate_embedderd_binary;
#[path = "supervisor_recycle.rs"]
mod supervisor_recycle;
pub use supervisor_recycle::{
FootprintSampler, RecycleClock, RecycleConfig, RecycleHooks, RecycleSnapshot, RecycleStats,
RecycleTrigger, process_recycle_stats,
};
use supervisor_recycle::{
RecycleCtx, RecycleOutcome, RecycleWatch, TrackedClient, perform_recycle,
};
pub struct EmbedderSupervisor {
binary_path: PathBuf,
child: Arc<tokio::sync::Mutex<Option<Child>>>,
client_slot: Arc<RwLock<Arc<dyn EmbedderClient>>>,
child_pid_slot: Arc<AtomicU32>,
unhealthy_signal: watch::Receiver<bool>,
terminated: Arc<std::sync::atomic::AtomicBool>,
config: SupervisorConfig,
tracked: Arc<TrackedClient>,
recycle_hooks: RecycleHooks,
}
impl EmbedderSupervisor {
pub async fn spawn_stdio(
binary_path: impl Into<PathBuf>,
config: SupervisorConfig,
) -> Result<(Self, Arc<RwLock<Arc<dyn EmbedderClient>>>, Arc<AtomicU32>)> {
let binary_path = binary_path.into();
let (child, client) = spawn_child(&binary_path, &config).await?;
let tracked = TrackedClient::new(client);
let unhealthy_signal = tracked.unhealthy_signal();
let initial_pid: u32 = child.id().unwrap_or(0);
let child_pid_slot = Arc::new(AtomicU32::new(initial_pid));
let client_slot: Arc<RwLock<Arc<dyn EmbedderClient>>> =
Arc::new(RwLock::new(Arc::clone(&tracked) as Arc<dyn EmbedderClient>));
let client_slot_clone = Arc::clone(&client_slot);
let child_pid_slot_clone = Arc::clone(&child_pid_slot);
let supervisor = Self {
binary_path,
child: Arc::new(tokio::sync::Mutex::new(Some(child))),
client_slot,
child_pid_slot,
unhealthy_signal,
terminated: Arc::new(std::sync::atomic::AtomicBool::new(false)),
config,
tracked,
recycle_hooks: RecycleHooks::default(),
};
Ok((supervisor, client_slot_clone, child_pid_slot_clone))
}
#[must_use]
pub fn with_recycle_hooks(mut self, hooks: RecycleHooks) -> Self {
self.recycle_hooks = hooks;
self
}
pub fn terminated_signal(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.terminated)
}
pub fn start_supervisor_task(self) -> SupervisorHandle {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let (gave_up_tx, gave_up_rx) = watch::channel(false);
let join = tokio::spawn(supervision_loop(
self.binary_path,
self.child,
self.client_slot,
self.child_pid_slot,
self.unhealthy_signal,
self.terminated,
self.config,
shutdown_rx,
gave_up_tx,
self.tracked,
RecycleWatch::new(&self.recycle_hooks),
self.recycle_hooks,
));
SupervisorHandle {
shutdown_tx,
join,
gave_up_rx,
}
}
pub async fn shutdown(self) {
let mut guard = self.child.lock().await;
if let Some(mut child) = guard.take() {
let _ = child.kill().await;
let _ = child.wait().await;
tracing::info!("EmbedderSupervisor: sidecar terminated on shutdown");
}
}
}
#[must_use = "dropping the handle without calling shutdown() leaves the \
sidecar running in the background with no way to stop it \
cooperatively later"]
pub struct SupervisorHandle {
shutdown_tx: watch::Sender<bool>,
join: tokio::task::JoinHandle<()>,
gave_up_rx: watch::Receiver<bool>,
}
impl SupervisorHandle {
pub async fn shutdown(self) {
let _ = self.shutdown_tx.send(true);
if let Err(e) = self.join.await {
tracing::warn!("EmbedderSupervisor: supervision task join error on shutdown: {e}");
}
}
pub fn has_given_up(&self) -> bool {
*self.gave_up_rx.borrow()
}
}
pub(super) async fn spawn_child(
binary_path: &Path,
config: &SupervisorConfig,
) -> Result<(Child, StdioEmbedderClient)> {
if let Some(bs) = config.sidecar_batch_size {
tracing::debug!(
bs,
"EmbedderSupervisor: forwarding TRUSTY_EMBED_BATCH_SIZE={bs}"
);
}
let mut child = spawn_retry::spawn_embedderd(binary_path, config)
.await
.with_context(|| {
format!(
"spawn trusty-embedderd --stdio from {}",
binary_path.display()
)
})?;
let stdin = child
.stdin
.take()
.context("child stdin handle missing (expected Stdio::piped)")?;
let stdout = child
.stdout
.take()
.context("child stdout handle missing (expected Stdio::piped)")?;
let client = StdioEmbedderClient::new(stdin, stdout);
let probe_result = tokio::time::timeout(
Duration::from_secs(config.startup_timeout_secs),
client.embed_batch(vec!["trusty-embedderd startup probe".to_string()]),
)
.await;
match probe_result {
Ok(Ok(_)) => {
tracing::info!(
binary = %binary_path.display(),
"EmbedderSupervisor: sidecar started and responding"
);
}
Ok(Err(e)) => {
anyhow::bail!("sidecar startup probe failed: {e}");
}
Err(_elapsed) => {
anyhow::bail!(
"sidecar did not respond within {}s (TRUSTY_EMBEDDERD_STARTUP_TIMEOUT_SECS={})",
config.startup_timeout_secs,
config.startup_timeout_secs
);
}
}
Ok((child, client))
}
enum RestartTrigger {
ProcessExit(std::process::ExitStatus),
Unhealthy,
RespawnFailed,
Recycle(RecycleTrigger),
}
fn wedge_counter_should_reset(elapsed_since_last: Option<Duration>, reset_secs: u64) -> bool {
match elapsed_since_last {
Some(elapsed) => elapsed >= Duration::from_secs(reset_secs),
None => false,
}
}
fn should_give_up(
consecutive_failures: u32,
consecutive_wedge_restarts: u32,
max_restarts: u32,
) -> bool {
consecutive_failures > max_restarts || consecutive_wedge_restarts > max_restarts
}
#[cfg(test)]
pub(crate) static SUPERVISION_LOOP_ITERATIONS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[allow(clippy::too_many_arguments)]
async fn supervision_loop(
binary_path: PathBuf,
child_slot: Arc<tokio::sync::Mutex<Option<Child>>>,
client_slot: Arc<RwLock<Arc<dyn EmbedderClient>>>,
child_pid_slot: Arc<AtomicU32>,
mut unhealthy_signal: watch::Receiver<bool>,
terminated: Arc<std::sync::atomic::AtomicBool>,
config: SupervisorConfig,
mut shutdown_rx: watch::Receiver<bool>,
gave_up_tx: watch::Sender<bool>,
mut tracked: Arc<TrackedClient>,
mut watch: RecycleWatch,
hooks: RecycleHooks,
) {
let mut consecutive_failures: u32 = 0;
let mut consecutive_wedge_restarts: u32 = 0;
let mut last_wedge_restart_at: Option<tokio::time::Instant> = None;
let mut last_failure_at: Option<tokio::time::Instant> = None;
let mut shutdown_closed = false;
let recycle_on = config.recycle.enabled();
let every = config.recycle.effective_check_interval();
let mut recycle_tick = tokio::time::interval_at(tokio::time::Instant::now() + every, every);
recycle_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
#[cfg(test)]
SUPERVISION_LOOP_ITERATIONS.fetch_add(1, AtomicOrdering::Relaxed);
if wedge_counter_should_reset(
last_wedge_restart_at.map(|t| t.elapsed()),
config.wedge_reset_secs,
) {
tracing::info!(
"EmbedderSupervisor: {}s without a further wedge — resetting wedge-restart \
escalation counter (was {consecutive_wedge_restarts})",
config.wedge_reset_secs,
);
consecutive_wedge_restarts = 0;
last_wedge_restart_at = None;
}
let unhealthy_open = unhealthy_signal.has_changed().is_ok();
let trigger = {
let mut guard = child_slot.lock().await;
match guard.as_mut() {
Some(child) => {
tokio::select! {
wait_result = child.wait() => match wait_result {
Ok(status) => RestartTrigger::ProcessExit(status),
Err(e) => {
tracing::error!("EmbedderSupervisor: wait() failed: {e}");
child_pid_slot.store(0, AtomicOrdering::Release);
return;
}
},
changed = unhealthy_signal.changed(), if unhealthy_open => {
if changed.is_err() {
tracing::debug!(
"EmbedderSupervisor: unhealthy_signal channel closed \
without firing — continuing to watch child.wait()"
);
continue;
}
RestartTrigger::Unhealthy
}
changed_result = shutdown_rx.changed(), if !shutdown_closed => {
let Ok(()) = changed_result else {
shutdown_closed = true;
continue;
};
if !*shutdown_rx.borrow() {
continue;
}
tracing::info!(
"EmbedderSupervisor: shutdown requested — stopping the \
sidecar and supervision loop (no respawn)"
);
if let Err(e) = child.start_kill() {
tracing::warn!(
"EmbedderSupervisor: start_kill on shutdown failed \
(may have already exited): {e}"
);
}
let _ = child.wait().await;
child_pid_slot.store(0, AtomicOrdering::Release);
return;
}
_ = recycle_tick.tick(), if recycle_on => {
let pid = child_pid_slot.load(AtomicOrdering::Acquire);
match watch.due(&config.recycle, &hooks, pid, &tracked) {
Some(reason) => RestartTrigger::Recycle(reason),
None => continue,
}
}
}
}
None => {
child_pid_slot.store(0, AtomicOrdering::Release);
RestartTrigger::RespawnFailed
}
}
};
if let RestartTrigger::Recycle(reason) = trigger {
let ctx = RecycleCtx {
binary_path: &binary_path,
config: &config,
hooks: &hooks,
child_slot: &child_slot,
client_slot: &client_slot,
child_pid_slot: &child_pid_slot,
shutdown_rx: &shutdown_rx,
};
tracked = match perform_recycle(ctx, reason, tracked).await {
RecycleOutcome::Swapped(fresh) => {
unhealthy_signal = fresh.unhealthy_signal();
watch.after_swap(&config.recycle, &hooks);
fresh
}
RecycleOutcome::SpawnFailed(kept) => {
watch.after_spawn_failure(&config.recycle, &hooks);
kept
}
RecycleOutcome::OldExited(kept) => kept,
};
continue;
}
if wedge_counter_should_reset(
last_failure_at.map(|t| t.elapsed()),
config.wedge_reset_secs,
) {
tracing::info!(
"EmbedderSupervisor: {}s without a further crash — resetting crash-storm \
escalation counter (was {consecutive_failures})",
config.wedge_reset_secs,
);
consecutive_failures = 0;
}
let is_wedge_restart = matches!(trigger, RestartTrigger::Unhealthy);
match trigger {
RestartTrigger::ProcessExit(exit_status) => {
child_pid_slot.store(0, AtomicOrdering::Release);
if exit_status.success() {
tracing::info!(
"EmbedderSupervisor: sidecar exited cleanly — stopping supervision"
);
return;
}
tracing::warn!(
"EmbedderSupervisor: sidecar exited with {:?} (failure #{}/{})",
exit_status.code(),
consecutive_failures + 1,
config.max_restarts,
);
}
RestartTrigger::Unhealthy => {
consecutive_wedge_restarts += 1;
last_wedge_restart_at = Some(tokio::time::Instant::now());
tracing::warn!(
"EmbedderSupervisor: sidecar client reported unhealthy (reader task \
died, or accumulating call timeouts indicate a wedged process) — \
forcing restart (failure #{}/{}, wedge-restart #{}/{})",
consecutive_failures + 1,
config.max_restarts,
consecutive_wedge_restarts,
config.max_restarts,
);
let mut guard = child_slot.lock().await;
if let Some(mut child) = guard.take() {
if let Err(e) = child.start_kill() {
tracing::warn!(
"EmbedderSupervisor: start_kill on wedged sidecar failed \
(may have already exited): {e}"
);
}
let _ = child.wait().await;
}
drop(guard);
child_pid_slot.store(0, AtomicOrdering::Release);
}
RestartTrigger::Recycle(_) => continue,
RestartTrigger::RespawnFailed => {
tracing::warn!(
"EmbedderSupervisor: previous respawn attempt failed with no live \
child left to observe — retrying (failure #{}/{})",
consecutive_failures + 1,
config.max_restarts,
);
}
}
consecutive_failures += 1;
last_failure_at = Some(tokio::time::Instant::now());
if should_give_up(
consecutive_failures,
consecutive_wedge_restarts,
config.max_restarts,
) {
tracing::error!(
"EmbedderSupervisor: exceeded max_restarts={} ({}) — giving up. \
Set TRUSTY_EMBEDDERD_MAX_RESTARTS to increase the limit, or \
TRUSTY_EMBEDDERD_WEDGE_RESET_SECS if wedges are recurring faster \
than the sustained-health reset window.",
config.max_restarts,
if consecutive_wedge_restarts > config.max_restarts {
"wedge-restart storm — recurring despite successful respawns"
} else {
"process-exit crash storm"
},
);
let _ = gave_up_tx.send(true);
terminated.store(true, AtomicOrdering::Release);
return;
}
let backoff_attempt = if is_wedge_restart {
consecutive_wedge_restarts
} else {
consecutive_failures
};
let delay_secs = (1u64 << backoff_attempt.min(16)).min(config.backoff_max_secs);
tracing::info!(
"EmbedderSupervisor: restarting sidecar in {delay_secs}s (attempt \
{consecutive_failures}{})",
if is_wedge_restart {
format!(", wedge-triggered, wedge-restart #{consecutive_wedge_restarts}")
} else {
String::new()
},
);
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(delay_secs)) => {}
changed_result = shutdown_rx.changed(), if !shutdown_closed => {
match changed_result {
Err(_) => {
shutdown_closed = true;
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
}
Ok(()) if *shutdown_rx.borrow() => {
tracing::info!(
"EmbedderSupervisor: shutdown requested during respawn \
back-off — stopping without respawn"
);
child_pid_slot.store(0, AtomicOrdering::Release);
return;
}
Ok(()) => {}
}
}
}
match spawn_child(&binary_path, &config).await {
Ok((new_child, new_client)) => {
let new_pid = new_child.id().unwrap_or(0);
let fresh = TrackedClient::new(new_client);
unhealthy_signal = fresh.unhealthy_signal();
{
let mut client_guard = client_slot.write().await;
*client_guard = Arc::clone(&fresh) as Arc<dyn EmbedderClient>;
}
tracked = fresh;
watch.reset(&hooks);
{
let mut child_guard = child_slot.lock().await;
*child_guard = Some(new_child);
}
child_pid_slot.store(new_pid, AtomicOrdering::Release);
tracing::info!(
"EmbedderSupervisor: sidecar restarted successfully (pid={new_pid})"
);
}
Err(e) => {
tracing::error!("EmbedderSupervisor: respawn failed: {e:#}");
}
}
}
}
#[cfg(test)]
#[path = "supervisor_tests.rs"]
mod tests;
#[cfg(all(test, unix))]
#[path = "supervisor_recycle_tests.rs"]
mod recycle_tests;