Skip to main content

aurum_core/runtime/
lifecycle.rs

1//! Synchronized lifecycle: Running → ShuttingDown → Stopped (JOE-1594).
2
3use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
4use std::time::{Duration, Instant};
5
6const STATE_RUNNING: u8 = 0;
7const STATE_SHUTTING_DOWN: u8 = 1;
8const STATE_STOPPED: u8 = 2;
9
10/// High-level lifecycle state.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum LifecycleState {
13    Running,
14    ShuttingDown,
15    Stopped,
16}
17
18/// Shutdown outcome when active work does not drain in time.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ShutdownError {
21    /// Timed out with N operations still active; contexts were NOT cleared.
22    Busy { active: usize },
23}
24
25/// Process/engine lifecycle with atomic admission and active-op accounting.
26///
27/// Admission and op registration happen in one synchronized path so shutdown
28/// cannot race a half-admitted operation.
29#[derive(Debug)]
30pub struct Lifecycle {
31    state: AtomicU8,
32    active: AtomicUsize,
33}
34
35impl Default for Lifecycle {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl Lifecycle {
42    pub fn new() -> Self {
43        Self {
44            state: AtomicU8::new(STATE_RUNNING),
45            active: AtomicUsize::new(0),
46        }
47    }
48
49    pub fn state(&self) -> LifecycleState {
50        match self.state.load(Ordering::SeqCst) {
51            STATE_RUNNING => LifecycleState::Running,
52            STATE_SHUTTING_DOWN => LifecycleState::ShuttingDown,
53            _ => LifecycleState::Stopped,
54        }
55    }
56
57    pub fn active_ops(&self) -> usize {
58        self.active.load(Ordering::SeqCst)
59    }
60
61    /// Atomically admit one operation if still Running.
62    ///
63    /// On success, active count is incremented; caller MUST drop the returned
64    /// [`OpAdmission`] (or call [`Self::end_op`]) when the op finishes.
65    pub fn try_begin_op(&self) -> Result<OpAdmission<'_>, AdmissionError> {
66        // Fast path: reject if not running.
67        let s = self.state.load(Ordering::SeqCst);
68        if s != STATE_RUNNING {
69            return Err(AdmissionError::NotRunning {
70                state: decode_state(s),
71            });
72        }
73        // Increment first, then re-check state to close the race with shutdown.
74        self.active.fetch_add(1, Ordering::SeqCst);
75        let s2 = self.state.load(Ordering::SeqCst);
76        if s2 != STATE_RUNNING {
77            self.active.fetch_sub(1, Ordering::SeqCst);
78            return Err(AdmissionError::NotRunning {
79                state: decode_state(s2),
80            });
81        }
82        Ok(OpAdmission { life: self })
83    }
84
85    pub(crate) fn end_op(&self) {
86        self.active.fetch_sub(1, Ordering::SeqCst);
87    }
88
89    /// Close admission and wait for active ops to reach zero.
90    ///
91    /// On success transitions to Stopped. On timeout leaves ShuttingDown and
92    /// returns [`ShutdownError::Busy`] — does **not** claim completion and does
93    /// **not** authorize cache clears.
94    ///
95    /// Idempotent: calling while already Stopped succeeds. Calling while
96    /// ShuttingDown continues waiting for the remaining active count.
97    pub fn shutdown(&self, timeout: Duration) -> Result<(), ShutdownError> {
98        let prev = self.state.load(Ordering::SeqCst);
99        if prev == STATE_STOPPED {
100            return Ok(());
101        }
102        // Running or ShuttingDown → ensure ShuttingDown.
103        self.state
104            .compare_exchange(
105                STATE_RUNNING,
106                STATE_SHUTTING_DOWN,
107                Ordering::SeqCst,
108                Ordering::SeqCst,
109            )
110            .ok();
111        // If another thread already stopped us, accept.
112        if self.state.load(Ordering::SeqCst) == STATE_STOPPED {
113            return Ok(());
114        }
115        self.state.store(STATE_SHUTTING_DOWN, Ordering::SeqCst);
116
117        let deadline = Instant::now() + timeout;
118        while self.active.load(Ordering::SeqCst) > 0 {
119            if Instant::now() >= deadline {
120                let active = self.active.load(Ordering::SeqCst);
121                return Err(ShutdownError::Busy { active });
122            }
123            std::thread::sleep(Duration::from_millis(5));
124        }
125        self.state.store(STATE_STOPPED, Ordering::SeqCst);
126        Ok(())
127    }
128
129    /// Force Stopped only when active is zero (test helper / finalizer).
130    pub fn mark_stopped_if_idle(&self) -> bool {
131        if self.active.load(Ordering::SeqCst) == 0 {
132            self.state.store(STATE_STOPPED, Ordering::SeqCst);
133            true
134        } else {
135            false
136        }
137    }
138
139    /// Whether new work may be admitted.
140    pub fn is_running(&self) -> bool {
141        self.state.load(Ordering::SeqCst) == STATE_RUNNING
142    }
143}
144
145fn decode_state(s: u8) -> LifecycleState {
146    match s {
147        STATE_RUNNING => LifecycleState::Running,
148        STATE_SHUTTING_DOWN => LifecycleState::ShuttingDown,
149        _ => LifecycleState::Stopped,
150    }
151}
152
153/// Error when admission is refused.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum AdmissionError {
156    NotRunning { state: LifecycleState },
157}
158
159impl std::fmt::Display for AdmissionError {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        match self {
162            Self::NotRunning { state } => {
163                write!(f, "runtime is not accepting work (state={state:?})")
164            }
165        }
166    }
167}
168
169impl std::error::Error for AdmissionError {}
170
171/// RAII admission ticket — drops unregister the op (panic-safe).
172pub struct OpAdmission<'a> {
173    life: &'a Lifecycle,
174}
175
176impl Drop for OpAdmission<'_> {
177    fn drop(&mut self) {
178        self.life.end_op();
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::sync::Arc;
186    use std::thread;
187
188    #[test]
189    fn admit_and_release() {
190        let life = Lifecycle::new();
191        let g = life.try_begin_op().unwrap();
192        assert_eq!(life.active_ops(), 1);
193        drop(g);
194        assert_eq!(life.active_ops(), 0);
195    }
196
197    #[test]
198    fn shutdown_blocks_admission() {
199        let life = Lifecycle::new();
200        life.shutdown(Duration::from_millis(100)).unwrap();
201        assert!(matches!(
202            life.try_begin_op(),
203            Err(AdmissionError::NotRunning { .. })
204        ));
205        assert_eq!(life.state(), LifecycleState::Stopped);
206    }
207
208    #[test]
209    fn shutdown_waits_for_active() {
210        let life = Arc::new(Lifecycle::new());
211        let g = life.try_begin_op().unwrap();
212        let life2 = Arc::clone(&life);
213        let h = thread::spawn(move || life2.shutdown(Duration::from_secs(2)));
214        thread::sleep(Duration::from_millis(50));
215        assert_eq!(life.state(), LifecycleState::ShuttingDown);
216        drop(g);
217        h.join().unwrap().unwrap();
218        assert_eq!(life.state(), LifecycleState::Stopped);
219    }
220
221    #[test]
222    fn shutdown_timeout_busy() {
223        let life = Lifecycle::new();
224        let _g = life.try_begin_op().unwrap();
225        let err = life.shutdown(Duration::from_millis(30)).unwrap_err();
226        assert!(matches!(err, ShutdownError::Busy { active: 1 }));
227        assert_eq!(life.state(), LifecycleState::ShuttingDown);
228    }
229
230    #[test]
231    fn shutdown_idempotent_when_stopped() {
232        let life = Lifecycle::new();
233        life.shutdown(Duration::from_millis(100)).unwrap();
234        life.shutdown(Duration::from_millis(100)).unwrap();
235        assert_eq!(life.state(), LifecycleState::Stopped);
236    }
237
238    #[test]
239    fn race_admission_with_shutdown() {
240        let life = Arc::new(Lifecycle::new());
241        let mut handles = vec![];
242        for _ in 0..8 {
243            let l = Arc::clone(&life);
244            handles.push(thread::spawn(move || {
245                for _ in 0..100 {
246                    if let Ok(g) = l.try_begin_op() {
247                        thread::sleep(Duration::from_micros(50));
248                        drop(g);
249                    }
250                }
251            }));
252        }
253        thread::sleep(Duration::from_millis(5));
254        let _ = life.shutdown(Duration::from_secs(2));
255        for h in handles {
256            h.join().unwrap();
257        }
258        assert_eq!(life.active_ops(), 0);
259    }
260}