Skip to main content

toolkit/
lifecycle.rs

1use async_trait::async_trait;
2use parking_lot::Mutex;
3use std::sync::{
4    Arc,
5    atomic::{AtomicBool, AtomicU8, Ordering},
6};
7use std::time::Duration;
8use tokio::sync::{Notify, oneshot};
9use tokio::task::JoinHandle;
10use tokio_util::sync::CancellationToken;
11
12// ----- Results & aliases -----------------------------------------------------
13
14/// Public result for lifecycle-level operations.
15type LcResult<T = ()> = std::result::Result<T, LifecycleError>;
16
17/// Result returned by user/background tasks.
18type TaskResult<T = ()> = anyhow::Result<T>;
19
20/// Type alias for ready function pointer to reduce complexity.
21type ReadyFn<T> = fn(
22    Arc<T>,
23    CancellationToken,
24    ReadySignal,
25)
26    -> std::pin::Pin<Box<dyn std::future::Future<Output = TaskResult<()>> + Send>>;
27
28// ----- Status model ----------------------------------------------------------
29
30/// Terminal/transition states for a background job.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[repr(u8)]
33pub enum Status {
34    Stopped,
35    Starting,
36    Running,
37    Stopping,
38}
39
40impl Status {
41    #[inline]
42    #[must_use]
43    pub const fn as_u8(self) -> u8 {
44        match self {
45            Status::Stopped => 0,
46            Status::Starting => 1,
47            Status::Running => 2,
48            Status::Stopping => 3,
49        }
50    }
51    #[inline]
52    #[must_use]
53    pub const fn from_u8(x: u8) -> Self {
54        match x {
55            1 => Status::Starting,
56            2 => Status::Running,
57            3 => Status::Stopping,
58            _ => Status::Stopped,
59        }
60    }
61}
62
63/// Reason why a task stopped.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StopReason {
66    Finished,
67    Cancelled,
68    Timeout,
69}
70
71// ----- Ready signal ----------------------------------------------------------
72
73/// Ready signal used by `start_with_ready*` to flip Starting -> Running.
74pub struct ReadySignal(oneshot::Sender<()>);
75
76impl ReadySignal {
77    #[inline]
78    pub fn notify(self) {
79        if self.0.send(()).is_err() {
80            tracing::debug!("ReadySignal::notify: receiver already dropped");
81        }
82    }
83    /// Construct a `ReadySignal` from a oneshot sender (used by macro-generated shims).
84    #[inline]
85    #[must_use]
86    pub fn from_sender(sender: tokio::sync::oneshot::Sender<()>) -> Self {
87        ReadySignal(sender)
88    }
89}
90
91// ----- Runnable --------------------------------------------------------------
92
93/// Trait for gears that can run a long-running task.
94/// Note: take `self` by `Arc` to make the spawned future `'static` and `Send`.
95#[async_trait]
96pub trait Runnable: Send + Sync + 'static {
97    /// Long-running loop. Must return when `cancel` is cancelled.
98    async fn run(self: Arc<Self>, cancel: CancellationToken) -> TaskResult<()>;
99}
100
101// ----- Errors ----------------------------------------------------------------
102
103/// Library-level error for lifecycle operations.
104#[derive(Debug, thiserror::Error)]
105pub enum LifecycleError {
106    #[error("already started")]
107    AlreadyStarted,
108}
109
110// ----- Lifecycle -------------------------------------------------------------
111
112/// Lifecycle controller for managing background tasks.
113///
114/// Concurrency notes:
115/// - State is tracked with atomics and `Notify`.
116/// - `handle` / `cancel` are protected by `Mutex`, and their locking scope is kept minimal.
117/// - All public start methods are thin wrappers around `start_core`.
118pub struct Lifecycle {
119    name: &'static str,
120    status: Arc<AtomicU8>,
121    handle: Mutex<Option<JoinHandle<()>>>,
122    cancel: Mutex<Option<CancellationToken>>,
123    /// `true` once the background task has fully finished.
124    finished: Arc<AtomicBool>,
125    /// Set to `true` when `stop()` requested cancellation.
126    was_cancelled: Arc<AtomicBool>,
127    /// Notifies all waiters when the task finishes.
128    finished_notify: Arc<Notify>,
129}
130
131impl Lifecycle {
132    #[must_use]
133    pub fn new_named(name: &'static str) -> Self {
134        Self {
135            name,
136            status: Arc::new(AtomicU8::new(Status::Stopped.as_u8())),
137            handle: Mutex::new(None),
138            cancel: Mutex::new(None),
139            finished: Arc::new(AtomicBool::new(false)),
140            was_cancelled: Arc::new(AtomicBool::new(false)),
141            finished_notify: Arc::new(Notify::new()),
142        }
143    }
144
145    #[must_use]
146    pub fn new() -> Self {
147        Self::new_named("lifecycle")
148    }
149
150    #[inline]
151    pub fn name(&self) -> &'static str {
152        self.name
153    }
154
155    // --- small helpers for atomics (keeps Ordering unified and code concise) ---
156
157    #[inline]
158    fn load_status(&self) -> Status {
159        Status::from_u8(self.status.load(Ordering::Acquire))
160    }
161
162    #[inline]
163    fn store_status(&self, s: Status) {
164        self.status.store(s.as_u8(), Ordering::Release);
165    }
166
167    // --- public start APIs delegate to start_core --------------------------------
168
169    /// Spawn the job using `make(cancel)`.
170    ///
171    /// The future is constructed inside the task to avoid leaving the lifecycle in `Starting`
172    /// if `make` panics.
173    ///
174    /// # Errors
175    /// Returns `LcError` if the lifecycle is not in a startable state.
176    #[tracing::instrument(skip(self, make), level = "debug")]
177    pub fn start<F, Fut>(&self, make: F) -> LcResult
178    where
179        F: FnOnce(CancellationToken) -> Fut + Send + 'static,
180        Fut: std::future::Future<Output = TaskResult<()>> + Send + 'static,
181    {
182        self.start_core(CancellationToken::new(), move |tok, _| make(tok), false)
183    }
184
185    /// Spawn the job using a provided `CancellationToken` and `make(cancel)`.
186    ///
187    /// # Errors
188    /// Returns `LcError` if the lifecycle is not in a startable state.
189    #[tracing::instrument(skip(self, make, token), level = "debug")]
190    pub fn start_with_token<F, Fut>(&self, token: CancellationToken, make: F) -> LcResult
191    where
192        F: FnOnce(CancellationToken) -> Fut + Send + 'static,
193        Fut: std::future::Future<Output = TaskResult<()>> + Send + 'static,
194    {
195        self.start_core(token, move |tok, _| make(tok), false)
196    }
197
198    /// Spawn the job using `make(cancel, ready)`. Status becomes `Running` only after `ready.notify()`.
199    ///
200    /// # Errors
201    /// Returns `LcError` if the lifecycle is not in a startable state.
202    #[tracing::instrument(skip(self, make), level = "debug")]
203    pub fn start_with_ready<F, Fut>(&self, make: F) -> LcResult
204    where
205        F: FnOnce(CancellationToken, ReadySignal) -> Fut + Send + 'static,
206        Fut: std::future::Future<Output = TaskResult<()>> + Send + 'static,
207    {
208        self.start_core(
209            CancellationToken::new(),
210            move |tok, rdy| async move {
211                let Some(rdy) = rdy else {
212                    return Err(anyhow::anyhow!("ReadySignal must be present"));
213                };
214                make(tok, rdy).await
215            },
216            true,
217        )
218    }
219
220    /// Ready-aware start variant that uses a provided `CancellationToken`.
221    ///
222    /// # Errors
223    /// Returns `LcError` if the lifecycle is not in a startable state.
224    #[tracing::instrument(skip(self, make, token), level = "debug")]
225    pub fn start_with_ready_and_token<F, Fut>(&self, token: CancellationToken, make: F) -> LcResult
226    where
227        F: FnOnce(CancellationToken, ReadySignal) -> Fut + Send + 'static,
228        Fut: std::future::Future<Output = TaskResult<()>> + Send + 'static,
229    {
230        self.start_core(
231            token,
232            move |tok, rdy| async move {
233                let Some(rdy) = rdy else {
234                    return Err(anyhow::anyhow!("ReadySignal must be present"));
235                };
236                make(tok, rdy).await
237            },
238            true,
239        )
240    }
241
242    /// Unified start core
243    ///
244    /// `ready_mode = true`   => we expect a `ReadySignal` to flip `Starting -> Running` (upon notify).
245    /// `ready_mode = false`  => we flip to `Running` immediately after spawn.
246    fn start_core<F, Fut>(&self, token: CancellationToken, make: F, ready_mode: bool) -> LcResult
247    where
248        F: Send + 'static + FnOnce(CancellationToken, Option<ReadySignal>) -> Fut,
249        Fut: std::future::Future<Output = TaskResult<()>> + Send + 'static,
250    {
251        // Stopped -> Starting (via CAS)
252        let cas_ok = self
253            .status
254            .compare_exchange(
255                Status::Stopped.as_u8(),
256                Status::Starting.as_u8(),
257                Ordering::AcqRel,
258                Ordering::Acquire,
259            )
260            .is_ok();
261        if !cas_ok {
262            return Err(LifecycleError::AlreadyStarted);
263        }
264
265        self.finished.store(false, Ordering::Release);
266        self.was_cancelled.store(false, Ordering::Release);
267
268        // store cancellation token (bounded lock scope)
269        {
270            let mut c = self.cancel.lock();
271            *c = Some(token.clone());
272        }
273
274        // In ready mode, we wait for `ready.notify()` to flip to Running.
275        // Otherwise, we mark Running immediately.
276        let (ready_tx, ready_rx) = oneshot::channel::<()>();
277        if ready_mode {
278            let status_on_ready = self.status.clone();
279            tokio::spawn(async move {
280                if ready_rx.await.is_ok() {
281                    _ = status_on_ready.compare_exchange(
282                        Status::Starting.as_u8(),
283                        Status::Running.as_u8(),
284                        Ordering::AcqRel,
285                        Ordering::Acquire,
286                    );
287                    tracing::debug!("lifecycle status -> running (ready)");
288                } else {
289                    // Sender dropped: task didn't signal readiness; we will remain in Starting
290                    // until finish. This is usually a bug or early-drop scenario.
291                    tracing::debug!("ready signal dropped; staying in Starting until finish");
292                }
293            });
294        } else {
295            self.store_status(Status::Running);
296            tracing::debug!("lifecycle status -> running");
297        }
298
299        let finished_flag = self.finished.clone();
300        let finished_notify = self.finished_notify.clone();
301        let status_on_finish = self.status.clone();
302
303        // Spawn the actual task with descriptive logging
304        let gear_name = self.name;
305        let task_id = format!("{gear_name}-{self:p}");
306        let handle = tokio::spawn(async move {
307            tracing::debug!(task_id = %task_id, gear =  %gear_name, "lifecycle task starting");
308            let res = make(token, ready_mode.then(|| ReadySignal(ready_tx))).await;
309            if let Err(e) = res {
310                tracing::error!(error=%e, task_id=%task_id, gear =  %gear_name, "lifecycle task error");
311            }
312            finished_flag.store(true, Ordering::Release);
313            finished_notify.notify_waiters();
314            status_on_finish.store(Status::Stopped.as_u8(), Ordering::Release);
315            tracing::debug!(task_id=%task_id, gear =  %gear_name, "lifecycle task finished");
316        });
317
318        // store handle (bounded lock scope)
319        {
320            let mut h = self.handle.lock();
321            *h = Some(handle);
322        }
323
324        Ok(())
325    }
326
327    /// Request graceful shutdown and wait up to `timeout`.
328    ///
329    /// # Errors
330    /// Returns `LcError` if the stop operation fails.
331    #[tracing::instrument(skip(self, timeout), level = "debug")]
332    pub async fn stop(&self, timeout: Duration) -> LcResult<StopReason> {
333        let gear_name = self.name;
334        let task_id = format!("{gear_name}-{self:p}");
335        let st = self.load_status();
336        if !matches!(st, Status::Starting | Status::Running | Status::Stopping) {
337            // Not running => already finished.
338            return Ok(StopReason::Finished);
339        }
340
341        self.store_status(Status::Stopping);
342
343        // Request cancellation only once (idempotent if multiple callers race here).
344        if let Some(tok) = { self.cancel.lock().take() } {
345            self.was_cancelled.store(true, Ordering::Release);
346            tok.cancel();
347        }
348
349        // Waiter that works for all callers, even after the task already finished.
350        let finished_flag = self.finished.clone();
351        let notify = self.finished_notify.clone();
352        let finished_wait = async move {
353            if finished_flag.load(Ordering::Acquire) {
354                return;
355            }
356            notify.notified().await;
357        };
358
359        let reason = tokio::select! {
360            () = finished_wait => {
361                if self.was_cancelled.load(Ordering::Acquire) {
362                    StopReason::Cancelled
363                } else {
364                    StopReason::Finished
365                }
366            }
367            () = tokio::time::sleep(timeout) => StopReason::Timeout,
368        };
369
370        // Join and ensure we notify waiters even if the task was aborted/panicked.
371        let handle_opt = { self.handle.lock().take() };
372        if let Some(handle) = handle_opt {
373            if matches!(reason, StopReason::Timeout) && !handle.is_finished() {
374                tracing::warn!("lifecycle stop timed out; aborting task");
375                handle.abort();
376            }
377
378            match handle.await {
379                Ok(()) => {
380                    tracing::debug!(task_id = %task_id, gear =  %gear_name, "lifecycle task completed successfully");
381                }
382                Err(e) if e.is_cancelled() => {
383                    tracing::debug!(task_id = %task_id, gear =  %gear_name, "lifecycle task was cancelled/aborted");
384                }
385                Err(e) if e.is_panic() => {
386                    // Extract panic information if possible
387                    match e.try_into_panic() {
388                        Ok(panic_payload) => {
389                            let panic_msg = panic_payload
390                                .downcast_ref::<&str>()
391                                .copied()
392                                .map(str::to_owned)
393                                .or_else(|| panic_payload.downcast_ref::<String>().cloned())
394                                .unwrap_or_else(|| "unknown panic".to_owned());
395
396                            tracing::error!(
397                                task_id = %task_id,
398                                gear =  %gear_name,
399                                panic_message = %panic_msg,
400                                "lifecycle task panicked - this indicates a serious bug"
401                            );
402                        }
403                        _ => {
404                            tracing::error!(
405                                task_id = %task_id,
406                                gear =  %gear_name,
407                                "lifecycle task panicked (could not extract panic message)"
408                            );
409                        }
410                    }
411                }
412                Err(e) => {
413                    tracing::warn!(task_id = %task_id, gear =  %gear_name, error = %e, "lifecycle task join error");
414                }
415            }
416
417            self.finished.store(true, Ordering::Release);
418            self.finished_notify.notify_waiters();
419        }
420
421        self.store_status(Status::Stopped);
422        tracing::info!(?reason, "lifecycle stopped");
423        Ok(reason)
424    }
425
426    /// Current status.
427    #[inline]
428    #[must_use]
429    pub fn status(&self) -> Status {
430        self.load_status()
431    }
432
433    /// Whether it is in `Starting` or `Running`.
434    #[inline]
435    pub fn is_running(&self) -> bool {
436        matches!(self.status(), Status::Starting | Status::Running)
437    }
438
439    /// Best-effort "try start" that swallows the error and returns bool.
440    #[inline]
441    #[must_use]
442    pub fn try_start<F, Fut>(&self, make: F) -> bool
443    where
444        F: FnOnce(CancellationToken) -> Fut + Send + 'static,
445        Fut: std::future::Future<Output = TaskResult<()>> + Send + 'static,
446    {
447        self.start(make).is_ok()
448    }
449
450    /// Wait until the task is fully stopped.
451    pub async fn wait_stopped(&self) {
452        if self.finished.load(Ordering::Acquire) {
453            return;
454        }
455        self.finished_notify.notified().await;
456    }
457}
458
459impl Default for Lifecycle {
460    fn default() -> Self {
461        Self::new()
462    }
463}
464
465impl Drop for Lifecycle {
466    /// Best-effort cleanup to avoid orphaned background tasks if caller forgets to call `stop()`.
467    fn drop(&mut self) {
468        if let Some(tok) = self.cancel.get_mut().take() {
469            tok.cancel();
470        }
471        if let Some(handle) = self.handle.get_mut().take() {
472            handle.abort();
473        }
474    }
475}
476
477// ----- WithLifecycle wrapper -------------------------------------------------
478
479/// Wrapper that implements `StatefulGear` for any `T: Runnable`.
480#[must_use]
481pub struct WithLifecycle<T: Runnable> {
482    inner: Arc<T>,
483    lc: Arc<Lifecycle>,
484    /// Maximum time to wait for the wrapped runnable to stop gracefully.
485    pub(crate) stop_timeout: Duration,
486    stop_timeout_fn: Option<fn(&T) -> Duration>,
487    // lifecycle start mode configuration
488    await_ready: bool,
489    has_ready_handler: bool,
490    run_ready_fn: Option<ReadyFn<T>>,
491}
492
493impl<T: Runnable> WithLifecycle<T> {
494    pub fn new(inner: T) -> Self {
495        Self {
496            inner: Arc::new(inner),
497            lc: Arc::new(Lifecycle::new_named(std::any::type_name::<T>())),
498            stop_timeout: Duration::from_secs(30),
499            stop_timeout_fn: None,
500            await_ready: false,
501            has_ready_handler: false,
502            run_ready_fn: None,
503        }
504    }
505
506    pub fn from_arc(inner: Arc<T>) -> Self {
507        Self {
508            inner,
509            lc: Arc::new(Lifecycle::new_named(std::any::type_name::<T>())),
510            stop_timeout: Duration::from_secs(30),
511            stop_timeout_fn: None,
512            await_ready: false,
513            has_ready_handler: false,
514            run_ready_fn: None,
515        }
516    }
517
518    pub fn new_with_name(inner: T, name: &'static str) -> Self {
519        Self {
520            inner: Arc::new(inner),
521            lc: Arc::new(Lifecycle::new_named(name)),
522            stop_timeout: Duration::from_secs(30),
523            stop_timeout_fn: None,
524            await_ready: false,
525            has_ready_handler: false,
526            run_ready_fn: None,
527        }
528    }
529
530    pub fn from_arc_with_name(inner: Arc<T>, name: &'static str) -> Self {
531        Self {
532            inner,
533            lc: Arc::new(Lifecycle::new_named(name)),
534            stop_timeout: Duration::from_secs(30),
535            stop_timeout_fn: None,
536            await_ready: false,
537            has_ready_handler: false,
538            run_ready_fn: None,
539        }
540    }
541
542    /// Set a custom stop timeout for graceful lifecycle shutdown.
543    ///
544    /// This is how long `Lifecycle::stop()` will wait for the task to finish
545    /// before aborting it.
546    ///
547    /// # Relationship with `HostRuntime::shutdown_deadline`
548    ///
549    /// When running under `HostRuntime`, this `stop_timeout` races against the
550    /// runtime's `shutdown_deadline` (`stop_timeout` defaults to 30s,
551    /// `shutdown_deadline` to 35s / [`DEFAULT_SHUTDOWN_DEADLINE`](crate::runtime::DEFAULT_SHUTDOWN_DEADLINE)). To ensure
552    /// deterministic behavior:
553    ///
554    /// - `stop_timeout` should be **less than** `shutdown_deadline`
555    /// - This allows the lifecycle's internal timeout to trigger first for graceful cleanup
556    /// - The runtime's `deadline_token` then acts as a hard backstop
557    ///
558    /// Example: `stop_timeout = 30s`, `shutdown_deadline = 35s`
559    pub fn with_stop_timeout(mut self, d: Duration) -> Self {
560        self.stop_timeout = d;
561        self.stop_timeout_fn = None;
562        self
563    }
564
565    /// Compute the stop timeout from the initialized runnable at each stop.
566    /// The host's deadline token still takes precedence.
567    pub fn with_stop_timeout_fn(mut self, provider: fn(&T) -> Duration) -> Self {
568        self.stop_timeout_fn = Some(provider);
569        self
570    }
571
572    #[inline]
573    #[must_use]
574    pub fn status(&self) -> Status {
575        self.lc.status()
576    }
577
578    #[inline]
579    #[must_use]
580    pub fn inner(&self) -> &T {
581        self.inner.as_ref()
582    }
583
584    /// Sometimes callers need to hold an `Arc` to the inner runnable.
585    #[inline]
586    #[must_use]
587    pub fn inner_arc(&self) -> Arc<T> {
588        self.inner.clone()
589    }
590
591    /// Configure readiness behavior produced by proc-macros (`#[toolkit::gear(..., lifecycle(...))]`).
592    pub fn with_ready_mode(
593        mut self,
594        await_ready: bool,
595        has_ready_handler: bool,
596        run_ready_fn: Option<ReadyFn<T>>,
597    ) -> Self {
598        self.await_ready = await_ready;
599        self.has_ready_handler = has_ready_handler;
600        self.run_ready_fn = run_ready_fn;
601        self
602    }
603}
604
605impl<T: Runnable + Default> Default for WithLifecycle<T> {
606    fn default() -> Self {
607        Self::new(T::default())
608    }
609}
610
611#[async_trait]
612impl<T: Runnable> crate::contracts::RunnableCapability for WithLifecycle<T> {
613    #[tracing::instrument(skip(self, external_cancel), level = "debug")]
614    async fn start(&self, external_cancel: CancellationToken) -> TaskResult<()> {
615        let inner = self.inner.clone();
616        let composed = external_cancel.child_token();
617
618        if !self.await_ready {
619            self.lc
620                .start_with_token(composed, move |cancel| inner.run(cancel))
621                .map_err(anyhow::Error::from)
622        } else if self.has_ready_handler {
623            let f = self.run_ready_fn.ok_or_else(|| {
624                anyhow::anyhow!("run_ready_fn must be set when has_ready_handler")
625            })?;
626            self.lc
627                .start_with_ready_and_token(composed, move |cancel, ready| f(inner, cancel, ready))
628                .map_err(anyhow::Error::from)
629        } else {
630            self.lc
631                .start_with_ready_and_token(composed, move |cancel, ready| async move {
632                    // Auto-notify readiness and continue with normal run()
633                    ready.notify();
634                    inner.run(cancel).await
635                })
636                .map_err(anyhow::Error::from)
637        }
638    }
639
640    /// Stop the lifecycle-managed task.
641    ///
642    /// Implements the two-phase shutdown contract:
643    /// 1. Attempts graceful stop using the configured duration or timeout provider
644    /// 2. If `deadline_token` is cancelled before graceful stop completes,
645    ///    immediately aborts with zero timeout
646    ///
647    /// The `deadline_token` is a fresh token from the runtime (not already cancelled),
648    /// allowing real graceful shutdown to occur.
649    #[tracing::instrument(skip(self, deadline_token), level = "debug")]
650    async fn stop(&self, deadline_token: CancellationToken) -> TaskResult<()> {
651        let timeout = self
652            .stop_timeout_fn
653            .map_or(self.stop_timeout, |provider| provider(&self.inner));
654        tokio::select! {
655            res = self.lc.stop(timeout) => {
656                _ = res.map_err(anyhow::Error::from)?;
657                Ok(())
658            }
659            () = deadline_token.cancelled() => {
660                // Hard-stop deadline reached, abort immediately
661                tracing::debug!("Hard-stop deadline reached, aborting lifecycle");
662                _ = self.lc.stop(Duration::from_millis(0)).await?;
663                Ok(())
664            }
665        }
666    }
667}
668
669impl<T: Runnable> Drop for WithLifecycle<T> {
670    /// Best-effort, but only if we're the last owner of `lc` to avoid aborting someone else's task.
671    fn drop(&mut self) {
672        if Arc::strong_count(&self.lc) == 1 {
673            if let Some(tok) = self.lc.cancel.lock().as_ref() {
674                tok.cancel();
675            }
676            if let Some(handle) = self.lc.handle.lock().as_ref() {
677                handle.abort();
678            }
679        }
680    }
681}
682
683// ----- Tests -----------------------------------------------------------------
684
685#[cfg(test)]
686#[cfg_attr(coverage_nightly, coverage(off))]
687mod tests {
688    use super::*;
689    use std::sync::atomic::{AtomicU32, Ordering as AOrd};
690    use tokio::time::{Duration, sleep};
691
692    struct TestRunnable {
693        counter: AtomicU32,
694    }
695
696    impl TestRunnable {
697        fn new() -> Self {
698            Self {
699                counter: AtomicU32::new(0),
700            }
701        }
702        fn count(&self) -> u32 {
703            self.counter.load(AOrd::Relaxed)
704        }
705    }
706
707    #[async_trait::async_trait]
708    impl Runnable for TestRunnable {
709        async fn run(self: Arc<Self>, cancel: CancellationToken) -> TaskResult<()> {
710            let mut interval = tokio::time::interval(Duration::from_millis(10));
711            loop {
712                tokio::select! {
713                    _ = interval.tick() => { self.counter.fetch_add(1, AOrd::Relaxed); }
714                    () = cancel.cancelled() => break,
715                }
716            }
717            Ok(())
718        }
719    }
720
721    #[tokio::test]
722    async fn lifecycle_basic() {
723        let lc = Arc::new(Lifecycle::new());
724        assert_eq!(lc.status(), Status::Stopped);
725
726        let result = lc.start(|cancel| async move {
727            cancel.cancelled().await;
728            Ok(())
729        });
730        assert!(result.is_ok());
731
732        let stop_result = lc.stop(Duration::from_millis(100)).await;
733        assert!(stop_result.is_ok());
734        assert_eq!(lc.status(), Status::Stopped);
735    }
736
737    #[tokio::test]
738    async fn with_lifecycle_wrapper_basics() {
739        let runnable = TestRunnable::new();
740        let wrapper = WithLifecycle::new(runnable);
741
742        assert_eq!(wrapper.status(), Status::Stopped);
743        assert_eq!(wrapper.inner().count(), 0);
744
745        let wrapper = wrapper.with_stop_timeout(Duration::from_mins(1));
746        assert_eq!(wrapper.stop_timeout.as_secs(), 60);
747    }
748
749    #[tokio::test]
750    async fn start_sets_running_immediately() {
751        let lc = Lifecycle::new();
752        lc.start(|cancel| async move {
753            cancel.cancelled().await;
754            Ok(())
755        })
756        .unwrap();
757
758        let s = lc.status();
759        assert!(matches!(s, Status::Running | Status::Starting));
760
761        let _ = lc.stop(Duration::from_millis(50)).await.unwrap();
762        assert_eq!(lc.status(), Status::Stopped);
763    }
764
765    #[tokio::test]
766    async fn start_with_ready_transitions_and_stop() {
767        let lc = Lifecycle::new();
768
769        let (ready_tx, ready_rx) = oneshot::channel::<()>();
770        lc.start_with_ready(move |cancel, ready| async move {
771            _ = ready_rx.await;
772            ready.notify();
773            cancel.cancelled().await;
774            Ok(())
775        })
776        .unwrap();
777
778        assert_eq!(lc.status(), Status::Starting);
779
780        _ = ready_tx.send(());
781        sleep(Duration::from_millis(10)).await;
782        assert_eq!(lc.status(), Status::Running);
783
784        let reason = lc.stop(Duration::from_millis(100)).await.unwrap();
785        assert!(matches!(
786            reason,
787            StopReason::Cancelled | StopReason::Finished
788        ));
789        assert_eq!(lc.status(), Status::Stopped);
790    }
791
792    #[tokio::test]
793    async fn stop_while_starting_before_ready() {
794        let lc = Lifecycle::new();
795
796        lc.start_with_ready(move |cancel, _ready| async move {
797            cancel.cancelled().await;
798            Ok(())
799        })
800        .unwrap();
801
802        assert_eq!(lc.status(), Status::Starting);
803
804        let reason = lc.stop(Duration::from_millis(100)).await.unwrap();
805        assert!(matches!(
806            reason,
807            StopReason::Cancelled | StopReason::Finished
808        ));
809        assert_eq!(lc.status(), Status::Stopped);
810    }
811
812    #[tokio::test]
813    async fn timeout_path_aborts_and_notifies() {
814        let lc = Lifecycle::new();
815
816        lc.start(|_cancel| async move {
817            loop {
818                sleep(Duration::from_secs(1000)).await;
819            }
820            #[allow(unreachable_code)]
821            Ok::<_, anyhow::Error>(())
822        })
823        .unwrap();
824
825        let reason = lc.stop(Duration::from_millis(30)).await.unwrap();
826        assert_eq!(reason, StopReason::Timeout);
827        assert_eq!(lc.status(), Status::Stopped);
828    }
829
830    #[tokio::test]
831    async fn try_start_and_second_start_fails() {
832        let lc = Lifecycle::new();
833
834        assert!(lc.try_start(|cancel| async move {
835            cancel.cancelled().await;
836            Ok(())
837        }));
838
839        let err = lc.start(|_c| async { Ok(()) }).unwrap_err();
840        match err {
841            LifecycleError::AlreadyStarted => {}
842        }
843
844        let _ = lc.stop(Duration::from_millis(80)).await.unwrap();
845        assert_eq!(lc.status(), Status::Stopped);
846    }
847
848    #[tokio::test]
849    async fn stop_is_idempotent_and_safe_concurrent() {
850        let lc = Arc::new(Lifecycle::new());
851
852        lc.start(|cancel| async move {
853            cancel.cancelled().await;
854            Ok(())
855        })
856        .unwrap();
857
858        let a = lc.clone();
859        let b = lc.clone();
860        let (r1, r2) = tokio::join!(
861            async move { a.stop(Duration::from_millis(80)).await },
862            async move { b.stop(Duration::from_millis(80)).await },
863        );
864
865        let r1 = r1.unwrap();
866        let r2 = r2.unwrap();
867        assert!(matches!(
868            r1,
869            StopReason::Finished | StopReason::Cancelled | StopReason::Timeout
870        ));
871        assert!(matches!(
872            r2,
873            StopReason::Finished | StopReason::Cancelled | StopReason::Timeout
874        ));
875        assert_eq!(lc.status(), Status::Stopped);
876    }
877
878    #[tokio::test]
879    async fn stateful_wrapper_start_stop_roundtrip() {
880        use crate::contracts::RunnableCapability;
881
882        let wrapper = WithLifecycle::new(TestRunnable::new());
883        assert_eq!(wrapper.status(), Status::Stopped);
884
885        wrapper.start(CancellationToken::new()).await.unwrap();
886        assert!(wrapper.lc.is_running());
887
888        wrapper.stop(CancellationToken::new()).await.unwrap();
889        assert_eq!(wrapper.status(), Status::Stopped);
890    }
891
892    #[tokio::test]
893    async fn with_lifecycle_double_start_fails() {
894        use crate::contracts::RunnableCapability;
895
896        let wrapper = WithLifecycle::new(TestRunnable::new());
897        let cancel = CancellationToken::new();
898        wrapper.start(cancel.clone()).await.unwrap();
899        let err = wrapper.start(cancel).await;
900        assert!(err.is_err());
901        wrapper.stop(CancellationToken::new()).await.unwrap();
902    }
903
904    #[tokio::test]
905    async fn with_lifecycle_concurrent_stop_calls() {
906        use crate::contracts::RunnableCapability;
907        let wrapper = Arc::new(WithLifecycle::new(TestRunnable::new()));
908        wrapper.start(CancellationToken::new()).await.unwrap();
909        let a = wrapper.clone();
910        let b = wrapper.clone();
911        let (r1, r2) = tokio::join!(
912            async move { a.stop(CancellationToken::new()).await },
913            async move { b.stop(CancellationToken::new()).await },
914        );
915        assert!(r1.is_ok());
916        assert!(r2.is_ok());
917        assert_eq!(wrapper.status(), Status::Stopped);
918    }
919
920    #[tokio::test]
921    async fn lifecycle_handles_panics_properly() {
922        let lc = Lifecycle::new();
923
924        // Start a task that will panic
925        lc.start(|_cancel| async {
926            panic!("test panic message");
927        })
928        .unwrap();
929
930        // Give the task a moment to start and panic
931        tokio::time::sleep(Duration::from_millis(50)).await;
932
933        // Stop should handle the panic gracefully
934        let reason = lc.stop(Duration::from_secs(1)).await.unwrap();
935
936        // The task panicked, but stop should complete successfully
937        // The exact reason depends on timing, but it should not hang or fail
938        assert!(matches!(
939            reason,
940            StopReason::Finished | StopReason::Cancelled | StopReason::Timeout
941        ));
942        assert_eq!(lc.status(), Status::Stopped);
943    }
944
945    #[tokio::test]
946    async fn lifecycle_task_naming_and_logging() {
947        let lc = Lifecycle::new();
948
949        // Start a simple task
950        lc.start(|cancel| async move {
951            cancel.cancelled().await;
952            Ok(())
953        })
954        .unwrap();
955
956        // Verify task is running
957        assert!(lc.is_running());
958
959        // Stop and verify proper cleanup
960        let reason = lc.stop(Duration::from_millis(100)).await.unwrap();
961        assert!(matches!(
962            reason,
963            StopReason::Cancelled | StopReason::Finished
964        ));
965        assert_eq!(lc.status(), Status::Stopped);
966    }
967
968    #[tokio::test]
969    async fn lifecycle_join_handles_all_tasks() {
970        let lc = Arc::new(Lifecycle::new());
971
972        // Start multiple tasks in sequence (lifecycle only supports one at a time)
973        lc.start(|cancel| async move {
974            tokio::time::sleep(Duration::from_millis(10)).await;
975            cancel.cancelled().await;
976            Ok(())
977        })
978        .unwrap();
979
980        // Stop should wait for the task to complete
981        let start = std::time::Instant::now();
982        let reason = lc.stop(Duration::from_millis(200)).await.unwrap();
983        let elapsed = start.elapsed();
984
985        // Should have waited at least 10ms for the task
986        assert!(elapsed >= Duration::from_millis(10));
987        assert!(matches!(
988            reason,
989            StopReason::Cancelled | StopReason::Finished
990        ));
991        assert_eq!(lc.status(), Status::Stopped);
992    }
993}