Skip to main content

rust_supervisor/runtime/
child_slot.rs

1//! ChildSlot placed on each supervised child runtime identity.
2//!
3//! A [`ChildSlot`] owns the live handles (cancellation token, join handle) for at
4//! most one active attempt at any moment. The control loop manipulates slots
5//! through the methods defined here rather than rewriting in-memory labels.
6
7use crate::child_runner::runner::{ChildRunHandle, ChildRunReport, wait_for_report};
8use crate::control::outcome::{
9    ChildAttemptStatus, ChildControlFailure, ChildControlOperation, ChildLivenessState,
10    ChildRuntimeRecord, ChildStopState, GenerationFenceState, RestartLimitState,
11};
12use crate::error::types::SupervisorError;
13use crate::id::types::{ChildId, ChildStartCount, Generation, SupervisorPath};
14use crate::readiness::signal::ReadinessState;
15use serde::Serialize;
16use std::collections::VecDeque;
17use std::fmt::{Display, Formatter};
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19use tokio::sync::watch;
20use tokio::task::AbortHandle;
21use tokio::time::Instant;
22use tokio_util::sync::CancellationToken;
23
24// ---------------------------------------------------------------------------
25// Shared types (migrated from child_runtime_state)
26// ---------------------------------------------------------------------------
27
28/// Default heartbeat stale threshold in seconds.
29pub const DEFAULT_HEARTBEAT_TIMEOUT_SECS: u64 = 5;
30
31/// Restart accounting history for one child runtime slot.
32#[derive(Debug, Clone, Default)]
33pub struct RestartLimitTracker {
34    /// Failure timestamps that are still relevant to the restart window.
35    failure_timestamps: VecDeque<u128>,
36}
37
38impl RestartLimitTracker {
39    /// Creates an empty restart limit tracker.
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Refreshes accounting and optionally records the current failed exit.
45    ///
46    /// # Arguments
47    ///
48    /// - `now_unix_nanos`: Current Unix timestamp in nanoseconds.
49    /// - `window`: Restart accounting window.
50    /// - `count_failure`: Whether the current exit should consume the limit.
51    ///
52    /// # Returns
53    ///
54    /// Returns the number of failures inside the active window.
55    pub fn refresh(&mut self, now_unix_nanos: u128, window: Duration, count_failure: bool) -> u32 {
56        self.prune(now_unix_nanos, window);
57        if count_failure {
58            self.failure_timestamps.push_back(now_unix_nanos);
59        }
60        self.failure_timestamps.len().min(u32::MAX as usize) as u32
61    }
62
63    /// Removes failure timestamps outside the accounting window.
64    fn prune(&mut self, now_unix_nanos: u128, window: Duration) {
65        let window_nanos = window.as_nanos();
66        while self
67            .failure_timestamps
68            .front()
69            .is_some_and(|timestamp| now_unix_nanos.saturating_sub(*timestamp) > window_nanos)
70        {
71            self.failure_timestamps.pop_front();
72        }
73    }
74}
75
76/// Runtime time base used to convert monotonic instants into Unix timestamps.
77#[derive(Debug, Clone, Copy)]
78pub struct RuntimeTimeBase {
79    /// Monotonic instant captured when the runtime starts.
80    pub base_instant: Instant,
81    /// Unix epoch timestamp in nanoseconds captured when the runtime starts.
82    pub base_unix_nanos: u128,
83}
84
85impl RuntimeTimeBase {
86    /// Creates a runtime time base.
87    pub fn new() -> Self {
88        Self {
89            base_instant: Instant::now(),
90            base_unix_nanos: SystemTime::now()
91                .duration_since(UNIX_EPOCH)
92                .map_or(0, |duration| duration.as_nanos()),
93        }
94    }
95
96    /// Returns the current Unix epoch timestamp in nanoseconds.
97    pub fn now_unix_nanos(&self) -> u128 {
98        self.instant_to_unix_nanos(Instant::now())
99    }
100
101    /// Converts a monotonic instant into a Unix epoch timestamp in nanoseconds.
102    ///
103    /// # Arguments
104    ///
105    /// - `instant`: Monotonic instant to convert.
106    pub fn instant_to_unix_nanos(&self, instant: Instant) -> u128 {
107        if instant >= self.base_instant {
108            self.base_unix_nanos
109                .saturating_add(instant.duration_since(self.base_instant).as_nanos())
110        } else {
111            self.base_unix_nanos
112                .saturating_sub(self.base_instant.duration_since(instant).as_nanos())
113        }
114    }
115}
116
117impl Default for RuntimeTimeBase {
118    /// Creates the default runtime time base.
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124// ---------------------------------------------------------------------------
125// ChildExitSummary
126// ---------------------------------------------------------------------------
127
128/// Summary recorded when a child attempt exits.
129#[derive(Debug, Clone, Serialize)]
130pub struct ChildExitSummary {
131    /// Process exit code when available.
132    pub exit_code: Option<i32>,
133    /// Human-readable exit reason.
134    pub exit_reason: String,
135    /// Unix epoch timestamp in nanoseconds when the exit was recorded.
136    pub exited_at_unix_nanos: u128,
137}
138
139impl ChildExitSummary {
140    /// Creates an exit summary from a [`ChildRunReport`].
141    ///
142    /// # Arguments
143    ///
144    /// - `report`: Completed child run report.
145    /// - `exited_at_unix_nanos`: Timestamp when the exit was observed.
146    ///
147    /// # Returns
148    ///
149    /// Returns a [`ChildExitSummary`].
150    pub fn from_report(report: &ChildRunReport, exited_at_unix_nanos: u128) -> Self {
151        let exit_reason = match &report.exit {
152            crate::child_runner::run_exit::TaskExit::Succeeded => "succeeded".to_owned(),
153            crate::child_runner::run_exit::TaskExit::Cancelled => "cancelled".to_owned(),
154            crate::child_runner::run_exit::TaskExit::Failed(f) => f.message.clone(),
155            crate::child_runner::run_exit::TaskExit::Panicked(msg) => format!("panicked: {msg}"),
156            crate::child_runner::run_exit::TaskExit::TimedOut => "timed out".to_owned(),
157        };
158        Self {
159            exit_code: None,
160            exit_reason,
161            exited_at_unix_nanos,
162        }
163    }
164}
165
166impl Display for ChildExitSummary {
167    /// Formats the exit summary as `code=<code> reason=<reason>`.
168    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
169        match self.exit_code {
170            Some(code) => write!(formatter, "code={} reason={}", code, self.exit_reason),
171            None => write!(formatter, "reason={}", self.exit_reason),
172        }
173    }
174}
175
176// ---------------------------------------------------------------------------
177// ChildSlot
178// ---------------------------------------------------------------------------
179
180/// Runtime slot for one supervised child.
181///
182/// At most one active attempt may occupy the slot at any moment. The slot owns
183/// the cancellation token, abort handle, and completion/health receivers for
184/// the active attempt.
185#[derive(Debug, Serialize)]
186pub struct ChildSlot {
187    /// Stable child identifier.
188    pub child_id: ChildId,
189    /// Child path in the supervisor tree.
190    pub path: SupervisorPath,
191    /// Current active attempt status.
192    pub status: ChildAttemptStatus,
193    /// Current control operation requested by the operator.
194    pub operation: ChildControlOperation,
195    /// Generation of the active attempt.
196    pub generation: Option<Generation>,
197    /// Monotonic attempt number for the active attempt.
198    pub attempt: Option<ChildStartCount>,
199    /// Cumulative restart count across all generations.
200    pub restart_count: u64,
201    /// Cancellation token for the active attempt (runtime-only, not serialized).
202    #[serde(skip)]
203    pub cancellation_token: Option<CancellationToken>,
204    /// Abort handle for the active attempt (runtime-only, not serialized).
205    #[serde(skip)]
206    pub abort_handle: Option<AbortHandle>,
207    /// Completion receiver for the active attempt (runtime-only, not serialized).
208    #[serde(skip)]
209    pub completion_receiver:
210        Option<watch::Receiver<Option<Result<ChildRunReport, SupervisorError>>>>,
211    /// Heartbeat receiver for the active attempt (runtime-only, not serialized).
212    #[serde(skip)]
213    pub heartbeat_receiver: Option<watch::Receiver<Option<Instant>>>,
214    /// Readiness receiver for the active attempt (runtime-only, not serialized).
215    #[serde(skip)]
216    pub readiness_receiver: Option<watch::Receiver<ReadinessState>>,
217    /// Summary of the most recent exit, if any.
218    pub last_exit: Option<ChildExitSummary>,
219    /// Unix epoch timestamp in nanoseconds when the child last reported ready.
220    pub last_ready_at: Option<u128>,
221    /// Unix epoch timestamp in nanoseconds of the last observed heartbeat.
222    pub last_heartbeat_at: Option<u128>,
223    /// Restart accounting window duration.
224    pub restart_window: Duration,
225    /// Whether a restart is pending but not yet activated.
226    pub pending_restart: bool,
227    /// Whether cancellation has been delivered to the active attempt.
228    pub attempt_cancel_delivered: bool,
229    /// Whether abort has been requested for the active attempt.
230    pub abort_requested: bool,
231    // --- Fields migrated from ChildRuntimeState for compatibility ---
232    /// Current restart limit state.
233    #[serde(skip)]
234    pub restart_limit: RestartLimitState,
235    /// Runtime-side restart accounting history.
236    #[serde(skip)]
237    pub restart_limit_tracker: RestartLimitTracker,
238    /// Current stop progress.
239    pub stop_state: ChildStopState,
240    /// Stop deadline in Unix epoch nanoseconds.
241    pub stop_deadline_at_unix_nanos: Option<u128>,
242    /// Most recent control failure.
243    pub last_control_failure: Option<ChildControlFailure>,
244    /// Attempt for the most recent stale heartbeat event.
245    pub stale_event_attempt: Option<ChildStartCount>,
246    /// Generation fencing state for restart coordination.
247    #[serde(skip)]
248    pub generation_fence: GenerationFenceState,
249    /// Registry identity anchor captured before a fenced restart.
250    #[serde(skip)]
251    pub registry_identity_anchor_for_spawn_attempt: Option<(Generation, ChildStartCount, u64)>,
252    /// Last observed readiness state.
253    #[serde(skip)]
254    pub last_observed_readiness: ReadinessState,
255    /// Heartbeat stale threshold (from ChildSpec.health_policy.stale_after).
256    #[serde(skip)]
257    pub stale_after: Duration,
258    /// Group membership (from ChildSpec.group) for structured group fuse.
259    #[serde(skip)]
260    pub group: Option<String>,
261    /// Whether this slot was ever orphaned by emergency_force_kill.
262    /// When true, subsequent restarts should use BlockingPool isolation
263    /// instead of the default async worker pool to avoid worker starvation.
264    #[serde(skip)]
265    pub orphaned: bool,
266}
267
268impl ChildSlot {
269    /// Creates an empty slot with no active attempt.
270    ///
271    /// # Arguments
272    ///
273    /// - `child_id`: Stable child identifier.
274    /// - `path`: Child path in the supervisor tree.
275    /// - `restart_window`: Restart accounting window duration.
276    ///
277    /// # Returns
278    ///
279    /// Returns a [`ChildSlot`] in idle state.
280    pub fn new(child_id: ChildId, path: SupervisorPath, restart_window: Duration) -> Self {
281        Self {
282            child_id,
283            path,
284            status: ChildAttemptStatus::Stopped,
285            operation: ChildControlOperation::Active,
286            generation: None,
287            attempt: None,
288            restart_count: 0,
289            cancellation_token: None,
290            abort_handle: None,
291            completion_receiver: None,
292            heartbeat_receiver: None,
293            readiness_receiver: None,
294            last_exit: None,
295            last_ready_at: None,
296            last_heartbeat_at: None,
297            restart_window,
298            pending_restart: false,
299            attempt_cancel_delivered: false,
300            abort_requested: false,
301            restart_limit: RestartLimitState::default(),
302            restart_limit_tracker: RestartLimitTracker::new(),
303            stop_state: ChildStopState::NoActiveAttempt,
304            stop_deadline_at_unix_nanos: None,
305            last_control_failure: None,
306            stale_event_attempt: None,
307            generation_fence: GenerationFenceState::placeholder(),
308            registry_identity_anchor_for_spawn_attempt: None,
309            last_observed_readiness: ReadinessState::Unreported,
310            stale_after: Duration::from_secs(DEFAULT_HEARTBEAT_TIMEOUT_SECS),
311            group: None,
312            orphaned: false,
313        }
314    }
315
316    /// Creates an empty slot with a default 60-second restart window.
317    ///
318    /// Convenience constructor for [`ChildSlot::new`] when the restart window
319    /// is not yet known.
320    ///
321    /// # Arguments
322    ///
323    /// - `child_id`: Stable child identifier.
324    /// - `path`: Child path in the supervisor tree.
325    ///
326    /// # Returns
327    ///
328    /// Returns a [`ChildSlot`] in idle state.
329    pub fn new_placeholder(child_id: ChildId, path: SupervisorPath) -> Self {
330        Self::new(child_id, path, Duration::from_secs(60))
331    }
332
333    /// Activates an attempt on this slot.
334    ///
335    /// # Arguments
336    ///
337    /// - `generation`: Generation owned by the active attempt.
338    /// - `attempt`: Monotonic attempt number.
339    /// - `status`: Initial active attempt status.
340    /// - `handle`: Child run handle carrying cancellation token and receivers.
341    ///
342    /// # Returns
343    ///
344    /// This function does not return a value.
345    pub fn activate(
346        &mut self,
347        generation: Generation,
348        attempt: ChildStartCount,
349        status: ChildAttemptStatus,
350        handle: ChildRunHandle,
351    ) {
352        self.generation = Some(generation);
353        self.attempt = Some(attempt);
354        self.status = status;
355        self.generation_fence.active_generation = Some(generation);
356        self.generation_fence.active_attempt = Some(attempt);
357        self.cancellation_token = Some(handle.cancellation_token);
358        self.abort_handle = Some(handle.abort_handle);
359        self.completion_receiver = Some(handle.completion_receiver);
360        self.heartbeat_receiver = Some(handle.heartbeat_receiver);
361        self.readiness_receiver = Some(handle.readiness_receiver);
362        self.last_exit = None;
363        self.last_ready_at = None;
364        self.last_heartbeat_at = None;
365        self.last_observed_readiness = ReadinessState::Unreported;
366        self.attempt_cancel_delivered = false;
367        self.abort_requested = false;
368        self.pending_restart = false;
369        self.stop_state = ChildStopState::Idle;
370        self.stop_deadline_at_unix_nanos = None;
371        self.last_control_failure = None;
372        self.stale_event_attempt = None;
373        self.registry_identity_anchor_for_spawn_attempt = None;
374        self.generation_fence.phase = GenerationFenceState::placeholder().phase;
375    }
376
377    /// Deactivates the current attempt and records its exit summary.
378    ///
379    /// The caller must have already awaited or consumed the completion
380    /// receiver. This method clears handles and advances the restart counter.
381    ///
382    /// # Arguments
383    ///
384    /// - `exit_summary`: Summary captured from the completed child run.
385    ///
386    /// # Returns
387    ///
388    /// This function does not return a value.
389    pub fn deactivate(&mut self, exit_summary: ChildExitSummary) {
390        self.last_exit = Some(exit_summary);
391        self.restart_count = self.restart_count.saturating_add(1);
392        self.generation = None;
393        self.attempt = None;
394        self.status = ChildAttemptStatus::Stopped;
395        self.cancellation_token = None;
396        self.abort_handle = None;
397        self.completion_receiver = None;
398        self.heartbeat_receiver = None;
399        self.readiness_receiver = None;
400        self.last_ready_at = None;
401        self.last_heartbeat_at = None;
402        self.attempt_cancel_delivered = false;
403        self.abort_requested = false;
404        self.pending_restart = false;
405        self.generation_fence.active_generation = None;
406        self.generation_fence.active_attempt = None;
407        self.stop_state = ChildStopState::NoActiveAttempt;
408        self.stop_deadline_at_unix_nanos = None;
409        self.stale_event_attempt = None;
410        self.registry_identity_anchor_for_spawn_attempt = None;
411    }
412
413    /// Clears the active instance without recording an exit.
414    pub fn clear_instance(&mut self) {
415        self.generation = None;
416        self.attempt = None;
417        self.status = ChildAttemptStatus::Stopped;
418        self.generation_fence.active_generation = None;
419        self.generation_fence.active_attempt = None;
420        self.cancellation_token = None;
421        self.abort_handle = None;
422        self.completion_receiver = None;
423        self.heartbeat_receiver = None;
424        self.readiness_receiver = None;
425        self.attempt_cancel_delivered = false;
426        self.abort_requested = false;
427        self.stop_deadline_at_unix_nanos = None;
428        self.stale_event_attempt = None;
429        self.registry_identity_anchor_for_spawn_attempt = None;
430        self.stop_state = ChildStopState::NoActiveAttempt;
431    }
432
433    /// Returns whether the slot currently holds an active attempt.
434    ///
435    /// # Arguments
436    ///
437    /// This function has no arguments.
438    ///
439    /// # Returns
440    ///
441    /// Returns `true` when an active attempt exists.
442    pub fn has_active_attempt(&self) -> bool {
443        self.attempt.is_some() && self.cancellation_token.is_some()
444    }
445
446    /// Delivers cancellation to the active attempt.
447    ///
448    /// # Arguments
449    ///
450    /// This function has no arguments.
451    ///
452    /// # Returns
453    ///
454    /// Returns `true` when this call delivered cancellation (first delivery).
455    pub fn cancel(&mut self) -> bool {
456        let Some(token) = &self.cancellation_token else {
457            return false;
458        };
459        if self.attempt_cancel_delivered {
460            return false;
461        }
462        token.cancel();
463        self.attempt_cancel_delivered = true;
464        self.status = ChildAttemptStatus::Cancelling;
465        true
466    }
467
468    /// Requests abort for the active attempt.
469    ///
470    /// # Arguments
471    ///
472    /// This function has no arguments.
473    ///
474    /// # Returns
475    ///
476    /// Returns `true` when this call requested abort (first request).
477    pub fn abort(&mut self) -> bool {
478        let Some(handle) = &self.abort_handle else {
479            return false;
480        };
481        if self.abort_requested {
482            return false;
483        }
484        handle.abort();
485        self.abort_requested = true;
486        true
487    }
488
489    /// Waits for the active attempt report.
490    ///
491    /// # Arguments
492    ///
493    /// This function has no arguments.
494    ///
495    /// # Returns
496    ///
497    /// Returns the completed child run report.
498    pub async fn wait_for_report(&mut self) -> Result<ChildRunReport, SupervisorError> {
499        let Some(receiver) = &mut self.completion_receiver else {
500            return Err(SupervisorError::InvalidTransition {
501                message: "child slot has no active completion receiver".to_owned(),
502            });
503        };
504        wait_for_report(receiver).await
505    }
506
507    /// Observes current readiness and heartbeat from the active attempt.
508    ///
509    /// # Arguments
510    ///
511    /// - `now_unix_nanos`: Current Unix epoch timestamp in nanoseconds.
512    ///
513    /// # Returns
514    ///
515    /// Returns the latest [`ChildLivenessState`].
516    pub fn observe_liveness(
517        &mut self,
518        now_unix_nanos: u128,
519        time_base: &RuntimeTimeBase,
520    ) -> ChildLivenessState {
521        if let Some(receiver) = &self.heartbeat_receiver {
522            let heartbeat = *receiver.borrow();
523            if let Some(instant) = heartbeat {
524                // Use the real heartbeat timestamp (converted from
525                // monotonic Instant to Unix nanos) instead of the
526                // observation time. This prevents polling from
527                // artificially refreshing the stale threshold.
528                self.last_heartbeat_at = Some(time_base.instant_to_unix_nanos(instant));
529            }
530        }
531        let readiness = if let Some(receiver) = &self.readiness_receiver {
532            let r = *receiver.borrow();
533            if r == ReadinessState::Ready {
534                self.last_ready_at = Some(now_unix_nanos);
535            }
536            r
537        } else {
538            ReadinessState::Unreported
539        };
540        let stale_after = self.stale_after;
541        let heartbeat_stale = self.last_heartbeat_at.is_some_and(|heartbeat| {
542            let elapsed_nanos = now_unix_nanos.saturating_sub(heartbeat);
543            elapsed_nanos >= stale_after.as_nanos()
544        });
545        ChildLivenessState::new(self.last_heartbeat_at, heartbeat_stale, readiness)
546    }
547
548    /// Updates restart limit state (migration compatibility with
549    /// `ChildRuntimeState::update_restart_limit` (migrated into [`ChildSlot`])).
550    ///
551    /// # Arguments
552    ///
553    /// - `window`: Restart accounting window.
554    /// - `limit`: Restart limit inside the window.
555    /// - `used`: Restart count used so far.
556    /// - `time_base`: Runtime time base.
557    ///
558    /// # Returns
559    ///
560    /// Returns the updated [`RestartLimitState`].
561    pub fn update_restart_limit(
562        &mut self,
563        window: Duration,
564        limit: u32,
565        used: u32,
566        time_base: &RuntimeTimeBase,
567    ) -> RestartLimitState {
568        let mut updated_at = time_base.now_unix_nanos();
569        if updated_at <= self.restart_limit.updated_at_unix_nanos {
570            updated_at = self.restart_limit.updated_at_unix_nanos.saturating_add(1);
571        }
572        self.restart_limit = RestartLimitState {
573            window,
574            limit,
575            used,
576            remaining: limit.saturating_sub(used),
577            exhausted: used >= limit,
578            updated_at_unix_nanos: updated_at,
579        };
580        self.restart_limit.clone()
581    }
582
583    /// Refreshes the restart limit tracker and updates the state.
584    pub fn refresh_restart_limit(
585        &mut self,
586        window: Duration,
587        limit: u32,
588        count_failure: bool,
589        time_base: &RuntimeTimeBase,
590    ) -> RestartLimitState {
591        let now = time_base.now_unix_nanos();
592        let used = self
593            .restart_limit_tracker
594            .refresh(now, window, count_failure);
595        self.update_restart_limit(window, limit, used, time_base)
596    }
597
598    /// Builds a public runtime state record.
599    pub fn to_record(&self, liveness: ChildLivenessState) -> ChildRuntimeRecord {
600        // Data model: status is None when there is no active attempt.
601        let status = if self.attempt.is_some() {
602            Some(self.status)
603        } else {
604            None
605        };
606        let pending_restart = self
607            .generation_fence
608            .pending_restart
609            .as_ref()
610            .map(crate::control::outcome::PendingRestartSummary::from);
611        ChildRuntimeRecord::new(
612            self.child_id.clone(),
613            self.path.clone(),
614            self.generation,
615            self.attempt,
616            status,
617            self.operation,
618            liveness,
619            self.restart_limit.clone(),
620            self.stop_state,
621            self.last_control_failure.clone(),
622            self.generation_fence.phase,
623            pending_restart,
624        )
625    }
626}