Skip to main content

kithara_platform/common/cancel/
group.rs

1use std::{
2    fmt,
3    future::Future,
4    ops::BitOr,
5    pin::Pin,
6    sync::Arc,
7    task::{Context, Poll},
8};
9
10use super::{token::CancelToken, wait::Cancelled};
11
12/// OR-combinator for cancellation tokens.
13///
14/// Fires when **any** source token is cancelled. No spawn — `is_cancelled()`
15/// polls each source, and [`cancelled`](CancelGroup::cancelled) is a single
16/// future that parks one slot on every source (no per-source boxed futures).
17///
18/// Supports composition via `|`:
19/// ```ignore
20/// let cancel = token_a | token_b;
21/// let cancel = group | extra_token;
22/// let cancel = group1 | group2;
23/// ```
24#[derive(Clone)]
25pub struct CancelGroup {
26    sources: Arc<[CancelToken]>,
27}
28
29impl CancelGroup {
30    #[must_use]
31    pub fn new(sources: Vec<CancelToken>) -> Self {
32        Self {
33            sources: sources.into(),
34        }
35    }
36
37    /// Future resolving once any source is cancelled. An empty group never
38    /// resolves. One future with a slot per source — dropping it unregisters
39    /// every slot (cancel-safe in `select!`).
40    #[must_use]
41    pub fn cancelled(&self) -> GroupCancelled<'_> {
42        GroupCancelled {
43            sources: &self.sources,
44            slots: Vec::new(),
45            done: false,
46        }
47    }
48
49    #[must_use]
50    pub fn is_cancelled(&self) -> bool {
51        self.sources.iter().any(CancelToken::is_cancelled)
52    }
53
54    fn tokens(&self) -> &[CancelToken] {
55        &self.sources
56    }
57}
58
59impl fmt::Debug for CancelGroup {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.debug_struct("CancelGroup")
62            .field("sources", &self.sources.len())
63            .field("cancelled", &self.is_cancelled())
64            .finish()
65    }
66}
67
68/// Identity comparison: two groups are equal iff they share the same underlying
69/// source array.
70impl PartialEq for CancelGroup {
71    fn eq(&self, other: &Self) -> bool {
72        Arc::ptr_eq(&self.sources, &other.sources)
73    }
74}
75
76impl From<CancelToken> for CancelGroup {
77    fn from(token: CancelToken) -> Self {
78        Self::new(vec![token])
79    }
80}
81
82impl From<Vec<CancelToken>> for CancelGroup {
83    fn from(tokens: Vec<CancelToken>) -> Self {
84        Self::new(tokens)
85    }
86}
87
88impl BitOr for CancelGroup {
89    type Output = Self;
90
91    fn bitor(self, rhs: Self) -> Self {
92        let mut tokens = self.tokens().to_vec();
93        tokens.extend_from_slice(rhs.tokens());
94        Self::new(tokens)
95    }
96}
97
98impl BitOr<CancelToken> for CancelGroup {
99    type Output = Self;
100
101    fn bitor(self, rhs: CancelToken) -> Self {
102        let mut tokens = self.tokens().to_vec();
103        tokens.push(rhs);
104        Self::new(tokens)
105    }
106}
107
108impl BitOr<CancelGroup> for CancelToken {
109    type Output = CancelGroup;
110
111    fn bitor(self, rhs: CancelGroup) -> CancelGroup {
112        let mut tokens = vec![self];
113        tokens.extend_from_slice(rhs.tokens());
114        CancelGroup::new(tokens)
115    }
116}
117
118/// Future returned by [`CancelGroup::cancelled`]. `Unpin`; parks one
119/// `cancelled()` future per source and resolves when any fires.
120pub struct GroupCancelled<'a> {
121    sources: &'a [CancelToken],
122    slots: Vec<Cancelled<'a>>,
123    done: bool,
124}
125
126impl Future for GroupCancelled<'_> {
127    type Output = ();
128
129    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
130        let me = self.get_mut();
131        if me.done {
132            return Poll::Ready(());
133        }
134        if me.slots.is_empty() {
135            if me.sources.is_empty() {
136                return Poll::Pending;
137            }
138            me.slots = me.sources.iter().map(CancelToken::cancelled).collect();
139        }
140        for slot in &mut me.slots {
141            if Pin::new(slot).poll(cx).is_ready() {
142                me.done = true;
143                me.slots.clear();
144                return Poll::Ready(());
145            }
146        }
147        Poll::Pending
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use std::time::Duration;
154
155    use kithara_test_utils::kithara;
156    use tokio::{spawn, task, time as tokio_time};
157
158    use super::CancelGroup;
159    use crate::common::cancel::CancelToken;
160
161    #[derive(Clone, Debug)]
162    enum Src {
163        Fresh,
164        ChildOf(usize),
165        PreCancelled,
166    }
167
168    #[derive(Clone, Debug)]
169    enum Act {
170        Source(usize),
171        Parent(usize),
172        None,
173    }
174
175    struct Setup {
176        group: CancelGroup,
177        parents: Vec<CancelToken>,
178        sources: Vec<CancelToken>,
179    }
180
181    fn build(spec: &[Src]) -> Setup {
182        let mut parents: Vec<CancelToken> = Vec::new();
183        let mut sources: Vec<CancelToken> = Vec::new();
184
185        for s in spec {
186            match s {
187                Src::Fresh => sources.push(CancelToken::never()),
188                Src::ChildOf(idx) => {
189                    while parents.len() <= *idx {
190                        parents.push(CancelToken::root());
191                    }
192                    sources.push(parents[*idx].child());
193                }
194                Src::PreCancelled => {
195                    let tok = CancelToken::never();
196                    tok.cancel();
197                    sources.push(tok);
198                }
199            }
200        }
201
202        let group = CancelGroup::new(sources.clone());
203        Setup {
204            group,
205            parents,
206            sources,
207        }
208    }
209
210    fn fire(act: &Act, s: &Setup) {
211        match act {
212            Act::Source(i) => s.sources[*i].cancel(),
213            Act::Parent(i) => s.parents[*i].cancel(),
214            Act::None => {}
215        }
216    }
217
218    macro_rules! sync_cancel_tests {
219        ($($name:ident: $spec:expr, $action:expr, $expected:expr;)*) => {
220            $(
221                #[kithara::test(timeout(Duration::from_secs(5)))]
222                fn $name() {
223                    let s = build(&$spec);
224                    fire(&$action, &s);
225                    assert_eq!(s.group.is_cancelled(), $expected);
226                }
227            )*
228        }
229    }
230
231    sync_cancel_tests! {
232        two_fresh_cancel_first:
233            [Src::Fresh, Src::Fresh], Act::Source(0), true;
234        two_fresh_cancel_second:
235            [Src::Fresh, Src::Fresh], Act::Source(1), true;
236        single_cancel:
237            [Src::Fresh], Act::Source(0), true;
238        two_fresh_no_cancel:
239            [Src::Fresh, Src::Fresh], Act::None, false;
240        pre_cancelled_plus_fresh:
241            [Src::PreCancelled, Src::Fresh], Act::None, true;
242        fresh_and_child_cancel_fresh:
243            [Src::Fresh, Src::ChildOf(0)], Act::Source(0), true;
244        fresh_and_child_cancel_parent:
245            [Src::Fresh, Src::ChildOf(0)], Act::Parent(0), true;
246        two_children_same_parent_cancel_parent:
247            [Src::ChildOf(0), Src::ChildOf(0)], Act::Parent(0), true;
248        two_children_diff_parents_cancel_first:
249            [Src::ChildOf(0), Src::ChildOf(1)], Act::Parent(0), true;
250        two_children_diff_parents_cancel_second:
251            [Src::ChildOf(0), Src::ChildOf(1)], Act::Parent(1), true;
252        two_children_diff_parents_no_cancel:
253            [Src::ChildOf(0), Src::ChildOf(1)], Act::None, false;
254        mixed_with_pre_cancelled:
255            [Src::Fresh, Src::ChildOf(0), Src::PreCancelled], Act::None, true;
256    }
257
258    macro_rules! async_cancel_tests {
259        ($($name:ident: $spec:expr, $action:expr;)*) => {
260            $(
261                #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
262                async fn $name() {
263                    let s = build(&$spec);
264                    let group2 = s.group.clone();
265                    let handle = spawn(async move { group2.cancelled().await });
266
267                    task::yield_now().await;
268
269                    assert!(!s.group.is_cancelled(), "must not be cancelled before action");
270                    fire(&$action, &s);
271
272                    tokio_time::timeout(Duration::from_secs(2), handle)
273                        .await
274                        .expect("BUG: cancelled() must resolve within the test timeout")
275                        .expect("BUG: spawned cancellation task must not panic");
276                }
277            )*
278        }
279    }
280
281    async_cancel_tests! {
282        async_two_fresh_cancel_first:
283            [Src::Fresh, Src::Fresh], Act::Source(0);
284        async_two_fresh_cancel_second:
285            [Src::Fresh, Src::Fresh], Act::Source(1);
286        async_single_cancel:
287            [Src::Fresh], Act::Source(0);
288        async_fresh_and_child_cancel_parent:
289            [Src::Fresh, Src::ChildOf(0)], Act::Parent(0);
290        async_two_children_same_parent:
291            [Src::ChildOf(0), Src::ChildOf(0)], Act::Parent(0);
292        async_two_children_diff_parents_cancel_first:
293            [Src::ChildOf(0), Src::ChildOf(1)], Act::Parent(0);
294        async_two_children_diff_parents_cancel_second:
295            [Src::ChildOf(0), Src::ChildOf(1)], Act::Parent(1);
296    }
297
298    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
299    async fn cancelled_resolves_immediately_when_pre_cancelled() {
300        let tok = CancelToken::never();
301        tok.cancel();
302        let group = CancelGroup::new(vec![tok, CancelToken::never()]);
303
304        tokio_time::timeout(Duration::from_secs(1), group.cancelled())
305            .await
306            .expect("BUG: cancelled() must return immediately for a pre-cancelled source");
307    }
308
309    #[kithara::test(timeout(Duration::from_secs(5)))]
310    fn empty_group_is_not_cancelled() {
311        let group = CancelGroup::new(vec![]);
312        assert!(!group.is_cancelled());
313    }
314
315    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
316    async fn empty_group_cancelled_never_resolves() {
317        let group = CancelGroup::new(vec![]);
318        let result = tokio_time::timeout(Duration::from_millis(50), group.cancelled()).await;
319        assert!(
320            result.is_err(),
321            "cancelled() on empty group must not resolve"
322        );
323    }
324
325    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
326    async fn clone_observes_same_cancellation() {
327        let tok = CancelToken::never();
328        let group = CancelGroup::new(vec![tok.clone()]);
329        let cloned = group.clone();
330
331        tok.cancel();
332        assert!(group.is_cancelled());
333        assert!(cloned.is_cancelled());
334    }
335
336    #[kithara::test(timeout(Duration::from_secs(5)))]
337    fn ptr_eq_via_partial_eq() {
338        // A clone shares the source array, a rebuilt group does not.
339        let tok = CancelToken::never();
340        let group = CancelGroup::from(tok.clone());
341        let cloned = group.clone();
342        let rebuilt = CancelGroup::from(tok);
343        assert_eq!(group, cloned);
344        assert_ne!(group, rebuilt);
345    }
346
347    #[kithara::test(timeout(Duration::from_secs(5)))]
348    fn token_bitor_token() {
349        let a = CancelToken::never();
350        let b = CancelToken::never();
351        let group = CancelGroup::from(a.clone()) | b.clone();
352
353        assert!(!group.is_cancelled());
354        a.cancel();
355        assert!(group.is_cancelled());
356    }
357
358    #[kithara::test(timeout(Duration::from_secs(5)))]
359    fn group_bitor_token() {
360        let a = CancelToken::never();
361        let b = CancelToken::never();
362        let group = CancelGroup::from(a.clone()) | b.clone();
363
364        assert!(!group.is_cancelled());
365        b.cancel();
366        assert!(group.is_cancelled());
367    }
368
369    #[kithara::test(timeout(Duration::from_secs(5)))]
370    fn token_bitor_group() {
371        let a = CancelToken::never();
372        let b = CancelToken::never();
373        let group = a.clone() | CancelGroup::from(b.clone());
374
375        assert!(!group.is_cancelled());
376        a.cancel();
377        assert!(group.is_cancelled());
378    }
379
380    #[kithara::test(timeout(Duration::from_secs(5)))]
381    fn group_bitor_group() {
382        let a = CancelToken::never();
383        let b = CancelToken::never();
384        let g1 = CancelGroup::from(a.clone());
385        let g2 = CancelGroup::from(b.clone());
386        let merged = g1 | g2;
387
388        assert!(!merged.is_cancelled());
389        b.cancel();
390        assert!(merged.is_cancelled());
391    }
392
393    #[kithara::test(timeout(Duration::from_secs(5)))]
394    fn chained_bitor() {
395        let a = CancelToken::never();
396        let b = CancelToken::never();
397        let c = CancelToken::never();
398        let group = CancelGroup::from(a.clone()) | b.clone() | c.clone();
399
400        assert!(!group.is_cancelled());
401        c.cancel();
402        assert!(group.is_cancelled());
403    }
404
405    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
406    async fn bitor_async_cancelled() {
407        let a = CancelToken::never();
408        let b = CancelToken::never();
409        let group = CancelGroup::from(a.clone()) | b.clone();
410
411        let g2 = group.clone();
412        let handle = spawn(async move { g2.cancelled().await });
413        task::yield_now().await;
414
415        assert!(!group.is_cancelled());
416        b.cancel();
417
418        tokio_time::timeout(Duration::from_secs(2), handle)
419            .await
420            .expect("BUG: cancelled() must resolve once one source has cancelled")
421            .expect("BUG: spawned task awaiting cancellation must not panic");
422    }
423}