Skip to main content

sley_core/
cancel.rs

1//! Cooperative stream cancellation.
2//!
3//! Sley's throughput paths are synchronous `Read`/`Write` loops. Cancellation
4//! is therefore cooperative: hot loops poll a [`CancelFlag`] between units of
5//! work (pack objects, compression windows, pkt-line frames, emit callbacks).
6//!
7//! # Design
8//!
9//! * [`AtomicCancel`] — shared atomic flag (UI stop, SIGINT, deadlines).
10//! * [`CancelFlag`] — cheap `Copy` handle: `Option<&AtomicCancel>` (no cancel
11//!   when `None`). Prefer this over generic monomorphization; every transfer
12//!   seam already type-erases to one shape.
13//! * [`StreamControl`] — continue/stop for callback-style event streams.
14//! * [`CancellableRead`] — `Read` adapter that fails with a **dedicated**
15//!   [`OperationCancelled`] payload (not [`io::ErrorKind::Interrupted`]).
16//!   Using `Interrupted` is wrong: `read_exact` and sley's pkt-line readers
17//!   treat it as EINTR and retry, spinning at 100% CPU after Ctrl-C.
18//!
19//! Pair with transport teardown ([`kill_child_if_cancelled`]) when a thread may
20//! be blocked in a kernel `read` on a pipe or socket.
21
22use crate::{GitError, Result};
23use std::error::Error as StdError;
24use std::fmt;
25use std::io::{self, Read};
26use std::process::Child;
27use std::sync::atomic::{AtomicBool, Ordering};
28
29/// Cooperative continue/stop control for callback-style event streams
30/// (status rows, revwalk commits, untracked paths, …).
31///
32/// Distinct from [`GitError::Cancelled`]: `Stop` is a successful early exit
33/// requested by the consumer; `Cancelled` is an error from an external cancel
34/// source (SIGINT, UI stop, deadline).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum StreamControl {
37    #[default]
38    Continue,
39    Stop,
40}
41
42impl StreamControl {
43    /// `true` when the consumer requested an early stop.
44    #[inline]
45    pub const fn is_stop(self) -> bool {
46        matches!(self, Self::Stop)
47    }
48
49    /// `true` when the consumer wants more items.
50    #[inline]
51    pub const fn is_continue(self) -> bool {
52        matches!(self, Self::Continue)
53    }
54}
55
56/// Shared atomic cancellation flag.
57///
58/// Safe to share across threads via [`Arc`](std::sync::Arc) or bare
59/// references. Setting the
60/// flag does not interrupt a thread blocked in kernel I/O; pair with
61/// [`CancellableRead`] for poll-between-reads, and [`kill_child_if_cancelled`]
62/// (or closing the HTTP body) for preemptive wake-up.
63#[derive(Debug, Default)]
64pub struct AtomicCancel {
65    cancelled: AtomicBool,
66}
67
68impl AtomicCancel {
69    /// Create a flag that is not yet cancelled.
70    #[inline]
71    pub const fn new() -> Self {
72        Self {
73            cancelled: AtomicBool::new(false),
74        }
75    }
76
77    /// Request cancellation. Subsequent polls return `true`. Idempotent.
78    #[inline]
79    pub fn cancel(&self) {
80        self.cancelled.store(true, Ordering::Release);
81    }
82
83    /// Clear the flag so a subsequent operation can reuse this source.
84    #[inline]
85    pub fn clear(&self) {
86        self.cancelled.store(false, Ordering::Release);
87    }
88
89    /// Current flag value.
90    #[inline]
91    pub fn is_cancelled(&self) -> bool {
92        self.cancelled.load(Ordering::Acquire)
93    }
94}
95
96/// Cheap cooperative cancel handle for long-running I/O.
97///
98/// `None` means never cancel (the common default). `Some` borrows an
99/// [`AtomicCancel`] owned by the CLI interrupt handler, UI, or embedder.
100///
101/// This is intentionally non-generic: every transfer path already needs a
102/// single type-erased shape for service structs (`FetchServices`, etc.).
103#[derive(Debug, Clone, Copy, Default)]
104pub struct CancelFlag<'a> {
105    source: Option<&'a AtomicCancel>,
106}
107
108impl CancelFlag<'static> {
109    /// A flag that never reports cancellation.
110    #[inline]
111    pub const fn never() -> Self {
112        Self { source: None }
113    }
114}
115
116impl<'a> CancelFlag<'a> {
117    /// Wrap a shared atomic cancel source.
118    #[inline]
119    pub const fn new(source: &'a AtomicCancel) -> Self {
120        Self {
121            source: Some(source),
122        }
123    }
124
125    /// Alias for [`CancelFlag::never`] used by older call sites.
126    #[inline]
127    pub const fn never_dyn() -> CancelFlag<'static> {
128        CancelFlag::never()
129    }
130
131    /// Whether cancellation has been requested.
132    #[inline]
133    pub fn is_cancelled(self) -> bool {
134        self.source.is_some_and(AtomicCancel::is_cancelled)
135    }
136
137    /// Return [`GitError::Cancelled`] when the flag is set; otherwise `Ok(())`.
138    #[inline]
139    pub fn check(self) -> Result<()> {
140        if self.is_cancelled() {
141            Err(GitError::Cancelled)
142        } else {
143            Ok(())
144        }
145    }
146
147    /// Map the flag into a [`StreamControl`] value for emit-style loops.
148    #[inline]
149    pub fn control(self) -> StreamControl {
150        if self.is_cancelled() {
151            StreamControl::Stop
152        } else {
153            StreamControl::Continue
154        }
155    }
156
157    /// Borrowed view with a possibly shorter lifetime (no-op for `Copy`).
158    #[inline]
159    pub fn as_ref(self) -> CancelFlag<'a> {
160        self
161    }
162
163    /// Underlying atomic, if any.
164    #[inline]
165    pub fn source(self) -> Option<&'a AtomicCancel> {
166        self.source
167    }
168}
169
170/// Historical name for the type-erased cancel handle; now identical to
171/// [`CancelFlag`].
172pub type DynCancelFlag<'a> = CancelFlag<'a>;
173
174/// Marker error stored inside [`io::Error`] when a cooperative cancel fires.
175///
176/// **Must not** use [`io::ErrorKind::Interrupted`]: `Read::read_exact` and
177/// sley's pkt-line readers treat that as EINTR and retry forever.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct OperationCancelled;
180
181impl fmt::Display for OperationCancelled {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.write_str("operation cancelled")
184    }
185}
186
187impl StdError for OperationCancelled {}
188
189/// `Read` adapter that polls a [`CancelFlag`] before each underlying read.
190///
191/// When cancelled, returns `Err` wrapping [`OperationCancelled`] with kind
192/// [`io::ErrorKind::Other`] (never `Interrupted`).
193#[derive(Debug)]
194pub struct CancellableRead<'a, R> {
195    inner: R,
196    cancel: CancelFlag<'a>,
197}
198
199impl<'a, R> CancellableRead<'a, R> {
200    /// Wrap `inner`, polling `cancel` before every read.
201    #[inline]
202    pub fn new(inner: R, cancel: CancelFlag<'a>) -> Self {
203        Self { inner, cancel }
204    }
205
206    /// Borrow the inner reader.
207    #[inline]
208    pub fn get_ref(&self) -> &R {
209        &self.inner
210    }
211
212    /// Mutably borrow the inner reader.
213    #[inline]
214    pub fn get_mut(&mut self) -> &mut R {
215        &mut self.inner
216    }
217
218    /// Unwrap the inner reader.
219    #[inline]
220    pub fn into_inner(self) -> R {
221        self.inner
222    }
223
224    /// The cancel flag driving this adapter.
225    #[inline]
226    pub fn cancel(&self) -> CancelFlag<'a> {
227        self.cancel
228    }
229}
230
231impl<R: Read> Read for CancellableRead<'_, R> {
232    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
233        if self.cancel.is_cancelled() {
234            return Err(cancelled_io_error());
235        }
236        self.inner.read(buf)
237    }
238}
239
240/// Build the I/O error used by [`CancellableRead`] on cancel.
241///
242/// Kind is **`Other`**, payload is [`OperationCancelled`]. Do not use
243/// `Interrupted` — see that type's docs.
244#[inline]
245pub fn cancelled_io_error() -> io::Error {
246    io::Error::other(OperationCancelled)
247}
248
249/// `true` when `err` is a cooperative cancel from [`CancellableRead`].
250#[inline]
251pub fn is_cancelled_io(err: &io::Error) -> bool {
252    err.get_ref()
253        .is_some_and(|inner| inner.downcast_ref::<OperationCancelled>().is_some())
254}
255
256/// Map a cancel-flavored I/O error to [`GitError::Cancelled`]; pass other
257/// errors through [`GitError::from`].
258#[inline]
259pub fn map_cancel_io(err: io::Error) -> GitError {
260    if is_cancelled_io(&err) {
261        GitError::Cancelled
262    } else {
263        GitError::from(err)
264    }
265}
266
267/// True when `err` is a cooperative cancellation.
268///
269/// Delegates to [`GitError::is_cancelled`], which uniformly covers the
270/// explicit `Cancelled` variant and cancel-flavored I/O errors.
271#[inline]
272pub fn is_cancelled_error(err: &GitError) -> bool {
273    err.is_cancelled()
274}
275
276/// Kill an OS child if cancel was requested (best-effort preemptive wake).
277#[inline]
278pub fn kill_child_if_cancelled(child: &mut Child, cancel: CancelFlag<'_>) {
279    if cancel.is_cancelled() {
280        let _ = child.kill();
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::io::{Cursor, Read};
288
289    #[test]
290    fn never_flag_is_never_cancelled() {
291        let flag = CancelFlag::never();
292        assert!(!flag.is_cancelled());
293        assert!(flag.check().is_ok());
294        assert_eq!(flag.control(), StreamControl::Continue);
295    }
296
297    #[test]
298    fn atomic_cancel_trips_flag() {
299        let source = AtomicCancel::new();
300        let flag = CancelFlag::new(&source);
301        assert!(!flag.is_cancelled());
302        source.cancel();
303        assert!(flag.is_cancelled());
304        assert_eq!(flag.check(), Err(GitError::Cancelled));
305        assert_eq!(flag.control(), StreamControl::Stop);
306        source.clear();
307        assert!(!flag.is_cancelled());
308    }
309
310    #[test]
311    fn cancellable_read_fails_with_operation_cancelled_not_interrupted() {
312        let source = AtomicCancel::new();
313        let data = b"hello world";
314        let mut reader = CancellableRead::new(Cursor::new(&data[..]), CancelFlag::new(&source));
315        let mut buf = [0u8; 5];
316        assert_eq!(reader.read(&mut buf).expect("read"), 5);
317        source.cancel();
318        let err = reader.read(&mut buf).expect_err("cancelled");
319        assert_ne!(
320            err.kind(),
321            io::ErrorKind::Interrupted,
322            "Interrupted is retried by read_exact / pkt-line"
323        );
324        assert!(is_cancelled_io(&err));
325        assert_eq!(map_cancel_io(err), GitError::Cancelled);
326    }
327
328    #[test]
329    fn read_exact_does_not_spin_on_cancel() {
330        let source = AtomicCancel::new();
331        source.cancel();
332        let mut reader = CancellableRead::new(Cursor::new(&b"abcd"[..]), CancelFlag::new(&source));
333        let mut buf = [0u8; 4];
334        // Would spin forever if cancel used ErrorKind::Interrupted.
335        let err = reader.read_exact(&mut buf).expect_err("cancelled");
336        assert!(is_cancelled_io(&err));
337    }
338
339    #[test]
340    fn never_dyn_alias_matches_never() {
341        assert!(!CancelFlag::never().is_cancelled());
342    }
343
344    #[test]
345    fn kill_child_if_cancelled_is_noop_when_not_cancelled() {
346        let mut child = std::process::Command::new("sleep")
347            .arg("60")
348            .stdout(std::process::Stdio::null())
349            .stderr(std::process::Stdio::null())
350            .spawn()
351            .expect("spawn sleep");
352        let source = AtomicCancel::new();
353        kill_child_if_cancelled(&mut child, CancelFlag::new(&source));
354        assert!(child.try_wait().expect("try_wait").is_none());
355        let _ = child.kill();
356        let _ = child.wait();
357    }
358
359    #[test]
360    fn kill_child_if_cancelled_kills_when_flag_set() {
361        let mut child = std::process::Command::new("sleep")
362            .arg("60")
363            .stdout(std::process::Stdio::null())
364            .stderr(std::process::Stdio::null())
365            .spawn()
366            .expect("spawn sleep");
367        let source = AtomicCancel::new();
368        source.cancel();
369        kill_child_if_cancelled(&mut child, CancelFlag::new(&source));
370        let status = child.wait().expect("wait after kill");
371        assert!(!status.success());
372    }
373}