Skip to main content

sim_cancel/
lib.rs

1//! Executor-neutral, explicitly owned cancellation for bounded work.
2#![forbid(unsafe_code)]
3#![deny(missing_docs)]
4
5use std::{
6    collections::BTreeMap,
7    fmt,
8    future::Future,
9    pin::Pin,
10    sync::{Arc, Mutex, Weak},
11    task::{Context, Poll, Waker},
12};
13
14/// Maximum UTF-8 byte length accepted for a cancellation reason.
15pub const MAX_REASON_BYTES: usize = 256;
16
17/// Embedded checked cookbook recipe tree.
18pub static RECIPES: sim_cookbook::EmbeddedDir =
19    include!(concat!(env!("OUT_DIR"), "/cookbook_recipes.rs"));
20
21/// The immutable explanation attached to the terminal transition.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct CancellationReason(Arc<str>);
24
25impl CancellationReason {
26    /// Validates and constructs a bounded, non-empty reason.
27    pub fn new(reason: impl Into<String>) -> Result<Self, InvalidReason> {
28        let reason = reason.into();
29        if reason.trim().is_empty() {
30            return Err(InvalidReason::Empty);
31        }
32        if reason.len() > MAX_REASON_BYTES {
33            return Err(InvalidReason::TooLong {
34                actual: reason.len(),
35                maximum: MAX_REASON_BYTES,
36            });
37        }
38        Ok(Self(reason.into()))
39    }
40    /// Returns the validated reason text.
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47/// Why construction of a cancellation reason failed.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum InvalidReason {
50    /// Whitespace-only and empty reasons carry no useful evidence.
51    Empty,
52    /// The reason exceeded the fixed memory and disclosure bound.
53    TooLong {
54        /// Supplied UTF-8 byte count.
55        actual: usize,
56        /// Accepted UTF-8 byte count.
57        maximum: usize,
58    },
59}
60impl fmt::Display for InvalidReason {
61    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(output, "invalid cancellation reason: {self:?}")
63    }
64}
65impl std::error::Error for InvalidReason {}
66
67#[derive(Debug)]
68struct Inner {
69    state: Mutex<State>,
70}
71#[derive(Debug, Default)]
72struct State {
73    reason: Option<CancellationReason>,
74    next_waiter: u64,
75    waiters: BTreeMap<u64, Waker>,
76    children: Vec<Weak<Inner>>,
77}
78
79/// Explicit cancellation authority and observer for one lifetime.
80///
81/// There is deliberately no global token or ambient lookup. Hosts construct a
82/// root, derive children for owned work, and pass those values explicitly.
83#[derive(Clone, Debug)]
84pub struct Cancellation {
85    inner: Arc<Inner>,
86}
87impl Default for Cancellation {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Cancellation {
94    /// Constructs an open, independent cancellation lifetime.
95    #[must_use]
96    pub fn new() -> Self {
97        Self {
98            inner: Arc::new(Inner {
99                state: Mutex::new(State::default()),
100            }),
101        }
102    }
103    /// Constructs an open child which follows this token's terminal state.
104    #[must_use]
105    pub fn child(&self) -> Self {
106        let child = Self::new();
107        let inherited = {
108            let mut state = self
109                .inner
110                .state
111                .lock()
112                .expect("cancellation mutex poisoned");
113            match state.reason.clone() {
114                Some(reason) => Some(reason),
115                None => {
116                    state.children.retain(|entry| entry.strong_count() > 0);
117                    state.children.push(Arc::downgrade(&child.inner));
118                    None
119                }
120            }
121        };
122        if let Some(reason) = inherited {
123            child.cancel(reason);
124        }
125        child
126    }
127    /// Performs the sole open-to-cancelled transition, returning true only to its winner.
128    pub fn cancel(&self, reason: CancellationReason) -> bool {
129        let (waiters, children) = {
130            let mut state = self
131                .inner
132                .state
133                .lock()
134                .expect("cancellation mutex poisoned");
135            if state.reason.is_some() {
136                return false;
137            }
138            state.reason = Some(reason.clone());
139            let waiters = std::mem::take(&mut state.waiters)
140                .into_values()
141                .collect::<Vec<_>>();
142            let children = std::mem::take(&mut state.children)
143                .into_iter()
144                .filter_map(|child| child.upgrade())
145                .collect::<Vec<_>>();
146            (waiters, children)
147        };
148        for child in children {
149            Self { inner: child }.cancel(reason.clone());
150        }
151        for waiter in waiters {
152            waiter.wake();
153        }
154        true
155    }
156    /// Observes the terminal reason without blocking.
157    #[must_use]
158    pub fn reason(&self) -> Option<CancellationReason> {
159        self.inner
160            .state
161            .lock()
162            .expect("cancellation mutex poisoned")
163            .reason
164            .clone()
165    }
166    /// Returns whether the terminal transition has occurred.
167    #[must_use]
168    pub fn is_cancelled(&self) -> bool {
169        self.reason().is_some()
170    }
171    /// Registers an executor-neutral waiter for the terminal reason.
172    #[must_use]
173    pub fn cancelled(&self) -> CancellationWaiter {
174        CancellationWaiter {
175            inner: Arc::downgrade(&self.inner),
176            registration: None,
177        }
178    }
179}
180
181/// A race-safe future resolving to the immutable terminal reason.
182///
183/// It holds only a weak token reference and unregisters its stored waker when dropped.
184#[derive(Debug)]
185pub struct CancellationWaiter {
186    inner: Weak<Inner>,
187    registration: Option<u64>,
188}
189impl Future for CancellationWaiter {
190    type Output = CancellationReason;
191    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
192        let Some(inner) = self.inner.upgrade() else {
193            return Poll::Pending;
194        };
195        let mut state = inner.state.lock().expect("cancellation mutex poisoned");
196        if let Some(reason) = state.reason.clone() {
197            if let Some(id) = self.registration.take() {
198                state.waiters.remove(&id);
199            }
200            return Poll::Ready(reason);
201        }
202        match self.registration {
203            Some(id) => {
204                if !state
205                    .waiters
206                    .get(&id)
207                    .is_some_and(|old| old.will_wake(cx.waker()))
208                {
209                    state.waiters.insert(id, cx.waker().clone());
210                }
211            }
212            None => {
213                let id = state.next_waiter;
214                state.next_waiter = state.next_waiter.wrapping_add(1);
215                state.waiters.insert(id, cx.waker().clone());
216                self.registration = Some(id);
217            }
218        }
219        Poll::Pending
220    }
221}
222impl Drop for CancellationWaiter {
223    fn drop(&mut self) {
224        let Some(id) = self.registration else { return };
225        if let Some(inner) = self.inner.upgrade()
226            && let Ok(mut state) = inner.state.lock()
227        {
228            state.waiters.remove(&id);
229        }
230    }
231}
232#[cfg(test)]
233mod tests;