Skip to main content

running_process/broker/server/
spawn_coordinator.rs

1//! Spawn coordination contract for broker-managed backends.
2//!
3//! This module does not launch child processes yet. It owns the state that
4//! Phase 4/5 launch code needs before spawning: per-backend-key budget windows,
5//! single-flight protection, retry-after hints for refused Hello replies, and
6//! process-wide file locks for backend spawn ownership.
7
8use std::collections::HashMap;
9use std::fs::File;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use super::backend_registry::BackendKey;
15
16/// Default backend spawn attempts allowed per budget window.
17pub const DEFAULT_SPAWN_ATTEMPTS_PER_WINDOW: u32 = 3;
18
19/// Default backend spawn budget window.
20pub const DEFAULT_SPAWN_BUDGET_WINDOW: Duration = Duration::from_secs(30);
21
22/// Acquire the backend spawn lock at `path`.
23///
24/// The returned guard owns an exclusive OS file lock until it is dropped. The
25/// lock file is intentionally left in place on drop; ownership is attached to
26/// the open file handle, not to path existence.
27///
28/// On Unix and Windows this helper verifies file identity after taking the
29/// lock. If another coordinator deletes, renames, or recreates the lock file
30/// between open and lock acquisition, the helper refuses the stale handle with
31/// [`SpawnLockError::DeletedOrRecreated`]. On platforms where file identity is
32/// not available, callers must keep lock files in a trusted broker-owned
33/// directory and treat path deletion/recreation detection as best-effort.
34pub fn acquire_spawn_lock(path: impl AsRef<Path>) -> Result<SpawnLockGuard, SpawnLockError> {
35    acquire_spawn_lock_with_hook(path.as_ref(), |_, _| {})
36}
37
38fn acquire_spawn_lock_with_hook<F>(
39    path: &Path,
40    mut before_lock: F,
41) -> Result<SpawnLockGuard, SpawnLockError>
42where
43    F: FnMut(&Path, &File),
44{
45    let path_buf = path.to_path_buf();
46    let file =
47        crate::platform::fs::open_lock_file(path).map_err(|source| SpawnLockError::Open {
48            path: path_buf.clone(),
49            source,
50        })?;
51
52    before_lock(path, &file);
53
54    crate::platform::fs::try_lock_exclusive(&file).map_err(|source| {
55        if crate::platform::fs::is_lock_conflict(&source) {
56            SpawnLockError::AlreadyLocked {
57                path: path_buf.clone(),
58            }
59        } else {
60            SpawnLockError::Lock {
61                path: path_buf.clone(),
62                source,
63            }
64        }
65    })?;
66
67    let opened_identity = crate::platform::fs::file_identity(&file)
68        .map_err(|source| lock_identity_error(&path_buf, &file, source))?;
69    let current_identity = match crate::platform::fs::path_identity(path) {
70        Ok(identity) => identity,
71        Err(source) if source.kind() == io::ErrorKind::NotFound => {
72            let _ = crate::platform::fs::unlock(&file);
73            return Err(SpawnLockError::DeletedOrRecreated {
74                path: path_buf,
75                opened_identity,
76                current_identity: None,
77            });
78        }
79        Err(source) => return Err(lock_identity_error(&path_buf, &file, source)),
80    };
81
82    if opened_identity != current_identity {
83        let _ = crate::platform::fs::unlock(&file);
84        return Err(SpawnLockError::DeletedOrRecreated {
85            path: path_buf,
86            opened_identity,
87            current_identity,
88        });
89    }
90
91    Ok(SpawnLockGuard {
92        file,
93        path: path_buf,
94        identity: opened_identity,
95    })
96}
97
98fn lock_identity_error(path: &Path, file: &File, source: io::Error) -> SpawnLockError {
99    let _ = crate::platform::fs::unlock(file);
100    SpawnLockError::Identity {
101        path: path.to_path_buf(),
102        source,
103    }
104}
105
106/// RAII guard for an acquired backend spawn lock.
107#[must_use = "dropping the guard releases the backend spawn lock immediately"]
108#[derive(Debug)]
109pub struct SpawnLockGuard {
110    file: File,
111    path: PathBuf,
112    identity: Option<SpawnLockFileIdentity>,
113}
114
115impl SpawnLockGuard {
116    /// Lock file path that was acquired.
117    pub fn path(&self) -> &Path {
118        &self.path
119    }
120
121    /// Platform file identity captured for the lock file, when available.
122    pub fn file_identity(&self) -> Option<SpawnLockFileIdentity> {
123        self.identity
124    }
125}
126
127impl Drop for SpawnLockGuard {
128    fn drop(&mut self) {
129        let _ = crate::platform::fs::unlock(&self.file);
130    }
131}
132
133/// Stable identity for an opened lock file on platforms that expose it.
134///
135/// Kept under this name for callers that already match on it; the mechanic is
136/// the facade's, because "is this still the same file" is a question only the
137/// host can answer.
138pub use crate::platform::fs::FileIdentity as SpawnLockFileIdentity;
139
140/// Errors returned while acquiring a backend spawn lock.
141#[derive(Debug, thiserror::Error)]
142pub enum SpawnLockError {
143    /// The lock path could not be opened or created.
144    #[error("failed to open backend spawn lock file {path}: {source}")]
145    Open {
146        /// Lock path.
147        path: PathBuf,
148        /// Underlying I/O error.
149        #[source]
150        source: io::Error,
151    },
152    /// Another broker worker already owns the lock.
153    #[error("backend spawn lock file {path} is already locked")]
154    AlreadyLocked {
155        /// Lock path.
156        path: PathBuf,
157    },
158    /// The platform lock operation failed for a reason other than contention.
159    #[error("failed to lock backend spawn lock file {path}: {source}")]
160    Lock {
161        /// Lock path.
162        path: PathBuf,
163        /// Underlying I/O error.
164        #[source]
165        source: io::Error,
166    },
167    /// The lock path no longer names the file that was locked.
168    #[error("backend spawn lock file {path} was deleted or recreated during acquisition")]
169    DeletedOrRecreated {
170        /// Lock path.
171        path: PathBuf,
172        /// Identity of the opened file handle.
173        opened_identity: Option<SpawnLockFileIdentity>,
174        /// Identity currently reachable through the lock path.
175        current_identity: Option<SpawnLockFileIdentity>,
176    },
177    /// File identity could not be read.
178    #[error("failed to verify backend spawn lock file identity for {path}: {source}")]
179    Identity {
180        /// Lock path.
181        path: PathBuf,
182        /// Underlying I/O error.
183        #[source]
184        source: io::Error,
185    },
186}
187
188/// Spawn-budget tuning.
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub struct SpawnBudgetConfig {
191    /// Maximum spawn attempts in one window.
192    pub max_attempts: u32,
193    /// Window duration.
194    pub window: Duration,
195}
196
197impl SpawnBudgetConfig {
198    /// Build a config, clamping zero values to safe non-zero defaults.
199    pub fn new(max_attempts: u32, window: Duration) -> Self {
200        Self {
201            max_attempts: max_attempts.max(1),
202            window: if window.is_zero() {
203                Duration::from_millis(1)
204            } else {
205                window
206            },
207        }
208    }
209}
210
211impl Default for SpawnBudgetConfig {
212    fn default() -> Self {
213        Self {
214            max_attempts: DEFAULT_SPAWN_ATTEMPTS_PER_WINDOW,
215            window: DEFAULT_SPAWN_BUDGET_WINDOW,
216        }
217    }
218}
219
220/// Coordinates bounded spawn attempts for backend keys.
221#[derive(Debug)]
222pub struct SpawnCoordinator {
223    config: SpawnBudgetConfig,
224    states: HashMap<BackendKey, SpawnBudgetState>,
225}
226
227impl SpawnCoordinator {
228    /// Create an empty coordinator with default budget settings.
229    pub fn new() -> Self {
230        Self::with_config(SpawnBudgetConfig::default())
231    }
232
233    /// Create an empty coordinator with explicit budget settings.
234    pub fn with_config(config: SpawnBudgetConfig) -> Self {
235        Self {
236            config,
237            states: HashMap::new(),
238        }
239    }
240
241    /// Begin one spawn attempt for `key`.
242    ///
243    /// The returned permit is a contract token for the caller that will perform
244    /// the actual child-process launch in later slices. Call [`Self::finish`]
245    /// when that launch path succeeds or fails.
246    pub fn try_begin(
247        &mut self,
248        key: BackendKey,
249        now: Instant,
250    ) -> Result<SpawnPermit, SpawnBeginError> {
251        let state = self
252            .states
253            .entry(key.clone())
254            .or_insert_with(|| SpawnBudgetState::new(now));
255        state.refresh(now, self.config.window);
256
257        if state.in_flight {
258            return Err(SpawnBeginError::AlreadyInProgress);
259        }
260
261        if state.attempts_used >= self.config.max_attempts {
262            let is_storm_trip = !state.storm_signaled;
263            state.storm_signaled = true;
264            return Err(SpawnBeginError::BudgetExhausted {
265                retry_after: retry_after(state.window_started_at, now, self.config.window),
266                remaining: 0,
267                is_storm_trip,
268            });
269        }
270
271        state.attempts_used += 1;
272        state.in_flight = true;
273        Ok(SpawnPermit {
274            key,
275            attempt_number: state.attempts_used,
276            remaining_after_begin: self.config.max_attempts - state.attempts_used,
277        })
278    }
279
280    /// Finish an in-flight spawn attempt.
281    pub fn finish(&mut self, key: &BackendKey, outcome: SpawnOutcome, now: Instant) {
282        let Some(state) = self.states.get_mut(key) else {
283            return;
284        };
285        state.refresh(now, self.config.window);
286        state.in_flight = false;
287        if outcome == SpawnOutcome::Success {
288            state.window_started_at = now;
289            state.attempts_used = 0;
290            state.storm_signaled = false;
291        }
292    }
293
294    /// Return the current budget snapshot for one backend key.
295    pub fn snapshot(&mut self, key: BackendKey, now: Instant) -> SpawnBudgetSnapshot {
296        let state = self
297            .states
298            .entry(key.clone())
299            .or_insert_with(|| SpawnBudgetState::new(now));
300        state.refresh(now, self.config.window);
301        snapshot_for(key, state, self.config, now)
302    }
303}
304
305impl Default for SpawnCoordinator {
306    fn default() -> Self {
307        Self::new()
308    }
309}
310
311/// Token returned for a spawn attempt that may proceed.
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub struct SpawnPermit {
314    /// Backend key this permit covers.
315    pub key: BackendKey,
316    /// 1-based attempt number inside the current window.
317    pub attempt_number: u32,
318    /// Budget remaining after this attempt starts.
319    pub remaining_after_begin: u32,
320}
321
322/// Result of a spawn attempt.
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub enum SpawnOutcome {
325    /// The backend process was launched and verified.
326    Success,
327    /// The backend process failed to launch or verify.
328    Failed,
329}
330
331/// Errors returned when a spawn attempt cannot begin.
332#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
333pub enum SpawnBeginError {
334    /// Another worker is already launching this backend key.
335    #[error("backend spawn already in progress")]
336    AlreadyInProgress,
337    /// The per-key spawn budget is exhausted.
338    #[error("backend spawn budget exhausted; retry after {retry_after:?}")]
339    BudgetExhausted {
340        /// Time until the budget window resets.
341        retry_after: Duration,
342        /// Remaining attempts, always zero for this variant.
343        remaining: u32,
344        /// True on the FIRST `try_begin` call to observe this key's budget
345        /// as exhausted within the current window; false on every
346        /// subsequent call until the window resets or a spawn succeeds.
347        /// A caller that reacts to the spawn-storm signal (e.g. rotating
348        /// the broker token) should gate on this to react exactly once
349        /// per storm, not once per refused request during it.
350        is_storm_trip: bool,
351    },
352}
353
354/// Current budget state for metrics/admin snapshots.
355#[derive(Clone, Debug, PartialEq, Eq)]
356pub struct SpawnBudgetSnapshot {
357    /// Backend key this snapshot describes.
358    pub key: BackendKey,
359    /// Attempts used in the active window.
360    pub attempts_used: u32,
361    /// Attempts still available in the active window.
362    pub remaining: u32,
363    /// Whether a spawn is currently in flight.
364    pub in_flight: bool,
365    /// Retry-after hint when no attempts remain.
366    pub retry_after: Option<Duration>,
367}
368
369#[derive(Clone, Debug)]
370struct SpawnBudgetState {
371    window_started_at: Instant,
372    attempts_used: u32,
373    in_flight: bool,
374    /// Whether this key's budget exhaustion has already been reported as a
375    /// storm trip in the current window (zackees/soldr#2364: "driving M
376    /// failed daemon spawns inside the window trips the guard exactly
377    /// once"). Every `try_begin` call after the first exhaustion keeps
378    /// returning `BudgetExhausted`, so a caller reacting to the FIRST one
379    /// (e.g. rotating the broker token) needs this to avoid reacting again
380    /// on every subsequent call in the same window.
381    storm_signaled: bool,
382}
383
384impl SpawnBudgetState {
385    fn new(now: Instant) -> Self {
386        Self {
387            window_started_at: now,
388            attempts_used: 0,
389            in_flight: false,
390            storm_signaled: false,
391        }
392    }
393
394    fn refresh(&mut self, now: Instant, window: Duration) {
395        if elapsed_since(self.window_started_at, now) >= window {
396            self.window_started_at = now;
397            self.attempts_used = 0;
398            self.in_flight = false;
399            self.storm_signaled = false;
400        }
401    }
402}
403
404fn snapshot_for(
405    key: BackendKey,
406    state: &SpawnBudgetState,
407    config: SpawnBudgetConfig,
408    now: Instant,
409) -> SpawnBudgetSnapshot {
410    let remaining = config.max_attempts.saturating_sub(state.attempts_used);
411    SpawnBudgetSnapshot {
412        key,
413        attempts_used: state.attempts_used,
414        remaining,
415        in_flight: state.in_flight,
416        retry_after: (remaining == 0)
417            .then(|| retry_after(state.window_started_at, now, config.window)),
418    }
419}
420
421fn retry_after(window_started_at: Instant, now: Instant, window: Duration) -> Duration {
422    window.saturating_sub(elapsed_since(window_started_at, now))
423}
424
425fn elapsed_since(started_at: Instant, now: Instant) -> Duration {
426    now.checked_duration_since(started_at)
427        .unwrap_or(Duration::ZERO)
428}
429
430#[cfg(test)]
431mod tests {
432    use std::fs;
433
434    use super::*;
435
436    #[test]
437    #[cfg(any(unix, windows))]
438    fn acquire_spawn_lock_detects_lock_file_replacement_between_open_and_lock() {
439        let tmp = tempfile::tempdir().unwrap();
440        let lock_path = tmp.path().join("backend.spawn.lock");
441        let replaced_path = tmp.path().join("backend.spawn.lock.replaced");
442
443        let err = acquire_spawn_lock_with_hook(&lock_path, |path, _file| {
444            fs::rename(path, &replaced_path).unwrap();
445            fs::write(path, b"replacement lock file").unwrap();
446        })
447        .unwrap_err();
448
449        let SpawnLockError::DeletedOrRecreated {
450            path,
451            opened_identity: Some(opened_identity),
452            current_identity: Some(current_identity),
453        } = err
454        else {
455            panic!("expected deleted/recreated error, got {err:?}");
456        };
457
458        assert_eq!(path, lock_path);
459        assert_ne!(opened_identity, current_identity);
460
461        let _guard = acquire_spawn_lock(&lock_path).unwrap();
462    }
463}