Skip to main content

protocol_droid/domain/session/
cancellation.rs

1//! Cancellation: why a request stopped, and how a waiter learns.
2//!
3//! # The two halves
4//!
5//! [`AbortReason`] is the domain's answer to "why did this request stop", and
6//! it is a value object with no machinery at all. [`CancellationToken`] is the
7//! standard-library plumbing that publishes that answer to whoever is awaiting
8//! it. The token is cloneable and shareable, and the reason is set exactly
9//! once: the first abort wins, and a second one is ignored rather than
10//! overwriting the peer's stated reason with a deadline.
11//!
12//! # No executor is named
13//!
14//! The token registers [`Waker`]s. It does not own a timer, a channel, or a
15//! task, and it does not require a particular runtime to be useful — a handler
16//! awaits [`CancellationToken::cancelled`] and finds out when the peer says so,
17//! whichever executor the handler is running on. That is what keeps the
18//! session engine free of a runtime dependency.
19//!
20//! # Why there is no lost-wakeup race
21//!
22//! The reason is published *before* the waker list is drained, and a waiter
23//! re-reads the reason *after* it has taken the lock. So a waiter that
24//! registered a moment too late still sees the reason on its re-read, and one
25//! that registered a moment too early has its waker in the list before the
26//! drain. There is no window in which a waker is both unregistered and the
27//! reason already set.
28
29use core::fmt;
30use core::pin::Pin;
31use core::task::{Context, Poll, Waker};
32use std::future::Future;
33use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
34
35/// Why an in-flight request stopped before its handler returned.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum AbortReason {
38    /// The peer sent its cancellation notification.
39    ///
40    /// The reason is the optional string that notification carried. The
41    /// specification says it MAY be logged or shown to a user, so it is
42    /// carried through rather than dropped, and it is a peer's own words
43    /// rather than something this side inferred.
44    Cancelled(Option<CancelReason>),
45    /// The request outlived the budget the tracker was given.
46    ///
47    /// Distinct from a cancellation because the two are reported differently:
48    /// a peer asked, or this side stopped waiting.
49    Expired,
50    /// The session closed underneath the request.
51    ///
52    /// Its own case rather than a cancellation, because nobody asked: the
53    /// work is abandoned, and a handler that treated it as a peer
54    /// cancellation would report a peer's decision that was never made.
55    SessionClosed,
56}
57
58impl AbortReason {
59    /// Whether a peer asked for this.
60    #[must_use]
61    pub const fn is_peer_requested(&self) -> bool {
62        matches!(self, Self::Cancelled(_))
63    }
64}
65
66impl fmt::Display for AbortReason {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::Cancelled(Some(reason)) => write!(formatter, "cancelled: {reason}"),
70            Self::Cancelled(None) => formatter.write_str("cancelled"),
71            Self::Expired => formatter.write_str("expired"),
72            Self::SessionClosed => formatter.write_str("session closed"),
73        }
74    }
75}
76
77/// The optional human-readable reason a cancellation notification carried.
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct CancelReason(String);
80
81impl CancelReason {
82    /// A stated reason.
83    #[must_use]
84    pub fn new(reason: impl Into<String>) -> Self {
85        Self(reason.into())
86    }
87
88    /// The text, as the peer wrote it.
89    #[must_use]
90    pub fn as_str(&self) -> &str {
91        &self.0
92    }
93}
94
95impl fmt::Display for CancelReason {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        formatter.write_str(&self.0)
98    }
99}
100
101#[derive(Debug, Default)]
102struct AbortState {
103    reason: OnceLock<AbortReason>,
104    waiters: Mutex<Vec<Waker>>,
105}
106
107impl AbortState {
108    /// The waker list.
109    ///
110    /// A poisoned lock is recovered rather than propagated. The guard only ever
111    /// holds `Vec<Waker>`, and the sole way it is held across a call that can
112    /// panic is `Waker::wake`, so the worst a poisoning can leave behind is a
113    /// waker for a future that has since been dropped — and waking that does
114    /// nothing at all. Failing every later cancellation on the session over
115    /// that would be a much worse failure than the one already recorded.
116    fn waiters(&self) -> MutexGuard<'_, Vec<Waker>> {
117        self.waiters.lock().unwrap_or_else(PoisonError::into_inner)
118    }
119}
120
121/// A shared, one-shot signal that a request was aborted.
122///
123/// Cloning shares the signal. [`cancel`](Self::cancel) is one-shot by
124/// construction: the reason is stored in a [`OnceLock`], so the first caller
125/// wins and later ones observe the same reason. That matters because a
126/// deadline and a peer's cancellation notification can land at the same
127/// instant, and whichever the request actually acted on is the one a
128/// post-mortem needs to see.
129#[derive(Debug, Clone, Default)]
130pub struct CancellationToken {
131    state: Arc<AbortState>,
132}
133
134impl CancellationToken {
135    /// A token that has not been aborted.
136    #[must_use]
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Aborts, unless something already has.
142    ///
143    /// Wakes every waiter currently registered. Returns whether *this* call is
144    /// the one that published the reason, which is what tells a caller racing a
145    /// deadline against a peer's notification which of the two the request
146    /// actually acted on.
147    pub fn cancel(&self, reason: AbortReason) -> bool {
148        let first = self.state.reason.set(reason).is_ok();
149        if first {
150            // Drain, never clear: a waiter that is about to register will
151            // re-read the reason and find it set, so nothing is lost by
152            // dropping the list, and the allocation is reused if a second
153            // abort ever arrives.
154            let mut waiters = self.state.waiters();
155            for waiter in waiters.drain(..) {
156                waiter.wake();
157            }
158        }
159        first
160    }
161
162    /// Whether the request was aborted.
163    #[must_use]
164    pub fn is_cancelled(&self) -> bool {
165        self.state.reason.get().is_some()
166    }
167
168    /// Why it was aborted, if it was.
169    #[must_use]
170    pub fn reason(&self) -> Option<&AbortReason> {
171        self.state.reason.get()
172    }
173
174    /// Resolves once the request is aborted.
175    ///
176    /// Never resolves before that, and never resolves twice.
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// # async fn demo() {
182    /// use protocol_droid::domain::session::{AbortReason, CancellationToken};
183    ///
184    /// let token = CancellationToken::new();
185    /// let observer = token.clone();
186    /// let signal = token.cancelled();
187    /// token.cancel(AbortReason::Expired);
188    /// signal.await;
189    /// assert!(observer.is_cancelled());
190    /// # }
191    /// ```
192    #[must_use]
193    pub const fn cancelled(&self) -> Cancelled<'_> {
194        Cancelled { token: self }
195    }
196}
197
198/// A future that resolves when a [`CancellationToken`] is aborted.
199///
200/// Created by [`CancellationToken::cancelled`]. A `'static` handle is available
201/// through [`CancellationToken::clone`] when a task has to own one.
202#[derive(Debug)]
203pub struct Cancelled<'a> {
204    token: &'a CancellationToken,
205}
206
207impl Future for Cancelled<'_> {
208    type Output = ();
209
210    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
211        if self.token.is_cancelled() {
212            return Poll::Ready(());
213        }
214        let mut waiters = self.token.state.waiters();
215        if self.token.is_cancelled() {
216            return Poll::Ready(());
217        }
218        if !waiters.iter().any(|w| w.will_wake(context.waker())) {
219            waiters.push(context.waker().clone());
220        }
221        Poll::Pending
222    }
223}
224
225#[cfg(test)]
226mod tests {
227
228    use core::future::Future;
229    use core::pin::Pin;
230    use core::sync::atomic::{AtomicUsize, Ordering};
231    use core::task::{Context, Poll};
232    use std::sync::Arc;
233    use std::task::{Wake, Waker};
234
235    use super::{AbortReason, CancelReason, CancellationToken};
236
237    /// Counts how many times it was woken, so a test can prove the abort
238    /// reached a waiter rather than merely being recorded.
239    struct Counting {
240        wakes: AtomicUsize,
241    }
242
243    impl Counting {
244        fn new() -> Arc<Self> {
245            Arc::new(Self {
246                wakes: AtomicUsize::new(0),
247            })
248        }
249
250        fn wakes(&self) -> usize {
251            self.wakes.load(Ordering::Relaxed)
252        }
253    }
254
255    impl Wake for Counting {
256        fn wake(self: Arc<Self>) {
257            self.wakes.fetch_add(1, Ordering::Relaxed);
258        }
259
260        fn wake_by_ref(self: &Arc<Self>) {
261            self.wakes.fetch_add(1, Ordering::Relaxed);
262        }
263    }
264
265    fn poll_once<F: Future>(future: Pin<&mut F>, waker: &Arc<Counting>) -> Poll<F::Output> {
266        let waker = Waker::from(waker.clone());
267        let mut context = Context::from_waker(&waker);
268        future.poll(&mut context)
269    }
270
271    #[test]
272    fn an_uncancelled_token_leaves_a_waiter_pending_and_does_not_wake_it() {
273        let token = CancellationToken::new();
274        let counter = Counting::new();
275        let mut signal = Box::pin(token.cancelled());
276        assert_eq!(poll_once(signal.as_mut(), &counter), Poll::Pending);
277        assert_eq!(counter.wakes(), 0, "nothing happened, so nothing to wake");
278        assert!(!token.is_cancelled());
279    }
280
281    #[test]
282    fn aborting_wakes_exactly_the_waiters_that_registered() {
283        let token = CancellationToken::new();
284        let counter = Counting::new();
285        let mut signal = Box::pin(token.cancelled());
286        assert_eq!(poll_once(signal.as_mut(), &counter), Poll::Pending);
287
288        assert!(token.cancel(AbortReason::Cancelled(Some(CancelReason::new("stop")))));
289        assert_eq!(counter.wakes(), 1, "the registered waiter was woken");
290        assert_eq!(poll_once(signal.as_mut(), &counter), Poll::Ready(()));
291    }
292
293    #[test]
294    fn a_token_cancelled_before_anything_waits_still_reads_as_cancelled() {
295        let token = CancellationToken::new();
296        let counter = Counting::new();
297        assert!(token.cancel(AbortReason::Expired));
298        // Nothing was waiting, so nobody is woken — but the signal is already
299        // ready, which is the case a handler that was slow to start hits.
300        assert_eq!(counter.wakes(), 0);
301        let mut signal = Box::pin(token.cancelled());
302        assert_eq!(poll_once(signal.as_mut(), &counter), Poll::Ready(()));
303    }
304
305    #[test]
306    fn a_second_abort_is_reported_as_a_no_op_and_keeps_the_first_reason() {
307        let token = CancellationToken::new();
308        assert!(token.cancel(AbortReason::Cancelled(Some(CancelReason::new("first")))));
309        assert!(
310            !token.cancel(AbortReason::Expired),
311            "the first reason is the one that happened"
312        );
313        let reason = token.reason();
314        assert!(
315            matches!(reason, Some(AbortReason::Cancelled(Some(inner))) if inner.as_str() == "first"),
316            "the first reason is the one that stuck, got {reason:?}"
317        );
318    }
319
320    #[test]
321    fn only_a_peer_cancellation_counts_as_peer_requested() {
322        assert!(AbortReason::Cancelled(None).is_peer_requested());
323        assert!(AbortReason::Cancelled(Some(CancelReason::new("why"))).is_peer_requested());
324        assert!(!AbortReason::Expired.is_peer_requested());
325        assert!(!AbortReason::SessionClosed.is_peer_requested());
326    }
327}