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}