Skip to main content

lspkit_sidecar/
lifecycle.rs

1//! Sidecar lifecycle: spawn, health, restart.
2//!
3//! Wraps a [`tokio::process::Child`] with health-ping monitoring and
4//! exponential-backoff respawn. The lifecycle manager is generic over the
5//! request/response types; the transport layer handles framing.
6
7use std::time::Duration;
8
9use tokio::process::{Child, Command};
10
11/// Lifecycle configuration.
12#[non_exhaustive]
13#[derive(Debug, Clone)]
14pub struct LifecycleConfig {
15    /// Interval between health pings.
16    pub ping_interval: Duration,
17    /// Per-ping timeout.
18    pub ping_timeout: Duration,
19    /// Initial backoff after a crash.
20    pub initial_backoff: Duration,
21    /// Backoff ceiling.
22    pub max_backoff: Duration,
23    /// Maximum number of consecutive failed restarts before giving up.
24    pub max_restarts: u32,
25}
26
27impl Default for LifecycleConfig {
28    fn default() -> Self {
29        Self {
30            ping_interval: Duration::from_secs(5),
31            ping_timeout: Duration::from_secs(2),
32            initial_backoff: Duration::from_secs(1),
33            max_backoff: Duration::from_secs(30),
34            max_restarts: u32::MAX,
35        }
36    }
37}
38
39/// Errors from the lifecycle manager.
40#[non_exhaustive]
41#[derive(Debug, thiserror::Error)]
42pub enum LifecycleError {
43    /// Spawning the child process failed.
44    #[error("spawn failed: {0}")]
45    Spawn(#[source] std::io::Error),
46    /// Restart budget exhausted.
47    #[error("restart budget exhausted after {0} attempts")]
48    BudgetExhausted(u32),
49}
50
51/// Owns a single sidecar child process.
52pub struct Sidecar {
53    program: String,
54    args: Vec<String>,
55    child: Option<Child>,
56    config: LifecycleConfig,
57    restart_count: u32,
58}
59
60impl Sidecar {
61    /// Construct, but do not yet spawn, a sidecar definition.
62    #[must_use]
63    pub fn new(program: impl Into<String>, args: Vec<String>, config: LifecycleConfig) -> Self {
64        Self {
65            program: program.into(),
66            args,
67            child: None,
68            config,
69            restart_count: 0,
70        }
71    }
72
73    /// Spawn the child process.
74    ///
75    /// # Errors
76    /// Returns [`LifecycleError::Spawn`] if the OS rejects the spawn.
77    pub fn spawn(&mut self) -> Result<(), LifecycleError> {
78        let child = Command::new(&self.program)
79            .args(&self.args)
80            .kill_on_drop(true)
81            .spawn()
82            .map_err(LifecycleError::Spawn)?;
83        self.child = Some(child);
84        Ok(())
85    }
86
87    /// Wait for the child to exit. Returns immediately if no child is running.
88    pub async fn wait(&mut self) -> Option<std::process::ExitStatus> {
89        if let Some(child) = self.child.as_mut() {
90            child.wait().await.ok()
91        } else {
92            None
93        }
94    }
95
96    /// Compute the next backoff duration using exponential growth capped at
97    /// `max_backoff`.
98    #[must_use]
99    pub fn next_backoff(&self) -> Duration {
100        let factor = 2_u32.saturating_pow(self.restart_count.min(20));
101        let scaled = self
102            .config
103            .initial_backoff
104            .checked_mul(factor)
105            .unwrap_or(self.config.max_backoff);
106        scaled.min(self.config.max_backoff)
107    }
108
109    /// Increment the restart counter; returns the new count.
110    pub fn record_restart(&mut self) -> u32 {
111        self.restart_count = self.restart_count.saturating_add(1);
112        self.restart_count
113    }
114
115    /// Reset the restart counter (e.g. after a successful health ping).
116    pub fn reset_restart_count(&mut self) {
117        self.restart_count = 0;
118    }
119
120    /// The configuration this sidecar was built with.
121    #[must_use]
122    pub fn config(&self) -> &LifecycleConfig {
123        &self.config
124    }
125}
126
127impl std::fmt::Debug for Sidecar {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("Sidecar")
130            .field("program", &self.program)
131            .field("running", &self.child.is_some())
132            .field("restarts", &self.restart_count)
133            .finish_non_exhaustive()
134    }
135}