Skip to main content

kithara_platform/common/cancel/
scope.rs

1use std::fmt;
2
3use super::token::CancelToken;
4
5/// A cancellation scope owned by a subsystem.
6///
7/// [`new`](CancelScope::new) is the canonical replacement for the legacy
8/// `cancel.unwrap_or_default()` fallback — the `Option<CancelToken>` parent picks
9/// the branch:
10/// - **composed** (`Some(parent)`): the scope's token is a child of the parent,
11///   so a parent/master cancel reaches this subtree.
12/// - **standalone** (`None`): the scope's token is itself a fresh root
13///   ([`CancelToken::root`]); nothing above can cancel it.
14///
15/// Either way, the inner children handed out by [`token`](CancelScope::token)
16/// derive from one node, and [`cancel`](CancelScope::cancel) cancels exactly that
17/// subtree. `Drop` is **passive**: dropping a scope does not cancel its subtree;
18/// teardown is an explicit `cancel()` (so a composed scope never cancels a token
19/// it was handed from above).
20pub struct CancelScope {
21    token: CancelToken,
22}
23
24impl CancelScope {
25    /// Build a scope from an optional parent token (the composed/standalone
26    /// seam). This is a domain constructor, not a generic `From` conversion: the
27    /// branch on `parent` decides ownership of the subtree root.
28    #[must_use]
29    pub fn new(parent: Option<CancelToken>) -> Self {
30        let token = parent.map_or_else(CancelToken::root, |parent| parent.child());
31        Self { token }
32    }
33
34    /// Cancel this scope's subtree (the node from which inner children derive).
35    pub fn cancel(&self) {
36        self.token.cancel();
37    }
38
39    #[must_use]
40    pub fn is_cancelled(&self) -> bool {
41        self.token.is_cancelled()
42    }
43
44    /// The scope's token. Clone it for the subsystem's own subtree (`.child()`
45    /// for per-task/per-fetch tokens).
46    #[must_use]
47    pub fn token(&self) -> CancelToken {
48        self.token.clone()
49    }
50}
51
52impl fmt::Debug for CancelScope {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.debug_struct("CancelScope")
55            .field("cancelled", &self.is_cancelled())
56            .finish_non_exhaustive()
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use std::time::Duration;
63
64    use kithara_test_utils::kithara;
65
66    use super::CancelScope;
67    use crate::common::cancel::CancelToken;
68
69    #[kithara::test(timeout(Duration::from_secs(5)))]
70    fn standalone_scope_owns_uncancelled_root() {
71        let scope = CancelScope::new(None);
72        assert!(!scope.is_cancelled());
73        assert!(!scope.token().is_cancelled());
74    }
75
76    #[kithara::test(timeout(Duration::from_secs(5)))]
77    fn standalone_cancel_cancels_own_subtree() {
78        let scope = CancelScope::new(None);
79        let token = scope.token();
80        scope.cancel();
81        assert!(scope.is_cancelled());
82        assert!(token.is_cancelled());
83    }
84
85    #[kithara::test(timeout(Duration::from_secs(5)))]
86    fn composed_scope_observes_parent_cancel() {
87        let parent = CancelToken::never();
88        let scope = CancelScope::new(Some(parent.clone()));
89        let token = scope.token();
90        assert!(!scope.is_cancelled());
91        parent.cancel();
92        assert!(scope.is_cancelled());
93        assert!(token.is_cancelled());
94    }
95
96    #[kithara::test(timeout(Duration::from_secs(5)))]
97    fn composed_scope_cancel_does_not_cancel_parent() {
98        // The single sanctioned semantic of the wave: a scope cancels only its
99        // own subtree, never the token it was handed from above.
100        let parent = CancelToken::never();
101        let scope = CancelScope::new(Some(parent.clone()));
102        scope.cancel();
103        assert!(scope.is_cancelled());
104        assert!(
105            !parent.is_cancelled(),
106            "scope.cancel() must not cancel the parent token it was handed"
107        );
108    }
109
110    #[kithara::test(timeout(Duration::from_secs(5)))]
111    fn drop_is_passive() {
112        // Dropping a scope must NOT cancel its subtree — teardown is explicit.
113        let parent = CancelToken::never();
114        let live = {
115            let scope = CancelScope::new(Some(parent.clone()));
116            scope.token()
117        };
118        assert!(
119            !live.is_cancelled() && !parent.is_cancelled(),
120            "dropping a scope must not cancel anything"
121        );
122    }
123}