Skip to main content

relux_runtime/
cancel.rs

1//! Cancellation primitives shared by the run scheduler, per-test watchdog,
2//! VM, BIFs, and effect manager. Wraps `tokio_util::sync::CancellationToken`
3//! with a per-token reason slot so observers can answer "why was I
4//! cancelled?" without needing a separate side channel.
5
6use std::sync::Arc;
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use tokio_util::sync::CancellationToken;
11
12#[derive(Debug, Clone)]
13pub enum CancelReason {
14    /// The per-test watchdog flipped this token because the test exceeded
15    /// its `effective_timeout`.
16    TestTimeout { duration: Duration },
17    /// The suite-wide watchdog fired.
18    SuiteTimeout { duration: Duration },
19    /// A sibling test failed and `RunStrategy::FailFast` is active.
20    FailFast { trigger_test: String },
21    /// The CLI process received SIGINT.
22    Sigint,
23}
24
25#[derive(Debug, Clone)]
26pub struct CancelToken {
27    inner: CancellationToken,
28    reason: Arc<OnceLock<CancelReason>>,
29    parent_reason: Option<Arc<OnceLock<CancelReason>>>,
30}
31
32impl CancelToken {
33    pub fn new() -> Self {
34        Self {
35            inner: CancellationToken::new(),
36            reason: Arc::new(OnceLock::new()),
37            parent_reason: None,
38        }
39    }
40
41    /// Derive a child token. Observes the parent's cancellation via
42    /// `tokio_util`'s `child_token`; falls back to the parent's reason slot
43    /// when its own slot is empty. Setting the child's reason via
44    /// `cancel_with` does not propagate to the parent.
45    pub fn child(&self) -> Self {
46        Self {
47            inner: self.inner.child_token(),
48            reason: Arc::new(OnceLock::new()),
49            parent_reason: Some(self.reason.clone()),
50        }
51    }
52
53    /// Set the local reason (first writer wins) and flip the cancel flag.
54    pub fn cancel_with(&self, reason: CancelReason) {
55        let _ = self.reason.set(reason);
56        self.inner.cancel();
57    }
58
59    #[cfg(test)]
60    pub fn cancel(&self) {
61        self.inner.cancel();
62    }
63
64    pub fn is_cancelled(&self) -> bool {
65        self.inner.is_cancelled()
66    }
67
68    pub fn reason(&self) -> Option<CancelReason> {
69        self.reason
70            .get()
71            .cloned()
72            .or_else(|| self.parent_reason.as_ref().and_then(|p| p.get().cloned()))
73    }
74
75    pub async fn cancelled(&self) {
76        self.inner.cancelled().await
77    }
78}
79
80impl Default for CancelToken {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn cancel_with_sets_reason_and_flag() {
92        let t = CancelToken::new();
93        assert!(!t.is_cancelled());
94        assert!(t.reason().is_none());
95        t.cancel_with(CancelReason::Sigint);
96        assert!(t.is_cancelled());
97        assert!(matches!(t.reason(), Some(CancelReason::Sigint)));
98    }
99
100    #[test]
101    fn cancel_with_first_writer_wins() {
102        let t = CancelToken::new();
103        t.cancel_with(CancelReason::Sigint);
104        t.cancel_with(CancelReason::FailFast {
105            trigger_test: "x".into(),
106        });
107        assert!(matches!(t.reason(), Some(CancelReason::Sigint)));
108    }
109
110    #[test]
111    fn clone_shares_local_slot() {
112        let a = CancelToken::new();
113        let b = a.clone();
114        a.cancel_with(CancelReason::Sigint);
115        assert!(b.is_cancelled());
116        assert!(matches!(b.reason(), Some(CancelReason::Sigint)));
117    }
118
119    #[test]
120    fn child_observes_parent_cancel_and_reason() {
121        let parent = CancelToken::new();
122        let child = parent.child();
123        assert!(!child.is_cancelled());
124        parent.cancel_with(CancelReason::SuiteTimeout {
125            duration: Duration::from_secs(1),
126        });
127        assert!(child.is_cancelled());
128        match child.reason() {
129            Some(CancelReason::SuiteTimeout { duration }) => {
130                assert_eq!(duration, Duration::from_secs(1));
131            }
132            other => panic!("unexpected reason: {other:?}"),
133        }
134    }
135
136    #[test]
137    fn child_local_reason_does_not_bubble_up() {
138        let parent = CancelToken::new();
139        let child = parent.child();
140        child.cancel_with(CancelReason::TestTimeout {
141            duration: Duration::from_millis(300),
142        });
143        assert!(child.is_cancelled());
144        assert!(matches!(
145            child.reason(),
146            Some(CancelReason::TestTimeout { .. })
147        ));
148        assert!(!parent.is_cancelled());
149        assert!(parent.reason().is_none());
150    }
151
152    #[test]
153    fn child_local_reason_preferred_over_parent_fallback() {
154        let parent = CancelToken::new();
155        let child = parent.child();
156        parent.cancel_with(CancelReason::FailFast {
157            trigger_test: "p".into(),
158        });
159        child.cancel_with(CancelReason::TestTimeout {
160            duration: Duration::from_millis(100),
161        });
162        assert!(matches!(
163            child.reason(),
164            Some(CancelReason::TestTimeout { .. })
165        ));
166        assert!(matches!(
167            parent.reason(),
168            Some(CancelReason::FailFast { .. })
169        ));
170    }
171
172    #[test]
173    fn cfg_test_cancel_flips_flag_without_reason() {
174        let t = CancelToken::new();
175        t.cancel();
176        assert!(t.is_cancelled());
177        assert!(t.reason().is_none());
178    }
179}