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::Arc;
28use std::sync::atomic::{AtomicBool, Ordering};
29
30/// Cooperative continue/stop control for callback-style event streams
31/// (status rows, revwalk commits, untracked paths, …).
32///
33/// Distinct from [`GitError::Cancelled`]: `Stop` is a successful early exit
34/// requested by the consumer; `Cancelled` is an error from an external cancel
35/// source (SIGINT, UI stop, deadline).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum StreamControl {
38    #[default]
39    Continue,
40    Stop,
41}
42
43impl StreamControl {
44    /// `true` when the consumer requested an early stop.
45    #[inline]
46    pub const fn is_stop(self) -> bool {
47        matches!(self, Self::Stop)
48    }
49
50    /// `true` when the consumer wants more items.
51    #[inline]
52    pub const fn is_continue(self) -> bool {
53        matches!(self, Self::Continue)
54    }
55}
56
57/// Shared atomic cancellation flag.
58///
59/// Safe to share across threads via [`Arc`] or bare 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#[inline]
269pub fn is_cancelled_error(err: &GitError) -> bool {
270    matches!(err, GitError::Cancelled)
271}
272
273/// Kill an OS child if cancel was requested (best-effort preemptive wake).
274#[inline]
275pub fn kill_child_if_cancelled(child: &mut Child, cancel: CancelFlag<'_>) {
276    if cancel.is_cancelled() {
277        let _ = child.kill();
278    }
279}
280
281/// Compatibility: treat a bare atomic reference as a cancel poll source.
282#[inline]
283pub fn cancel_flag_from_arc(source: &Arc<AtomicCancel>) -> CancelFlag<'_> {
284    CancelFlag::new(source.as_ref())
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::io::{Cursor, Read};
291
292    #[test]
293    fn never_flag_is_never_cancelled() {
294        let flag = CancelFlag::never();
295        assert!(!flag.is_cancelled());
296        assert!(flag.check().is_ok());
297        assert_eq!(flag.control(), StreamControl::Continue);
298    }
299
300    #[test]
301    fn atomic_cancel_trips_flag() {
302        let source = AtomicCancel::new();
303        let flag = CancelFlag::new(&source);
304        assert!(!flag.is_cancelled());
305        source.cancel();
306        assert!(flag.is_cancelled());
307        assert_eq!(flag.check(), Err(GitError::Cancelled));
308        assert_eq!(flag.control(), StreamControl::Stop);
309        source.clear();
310        assert!(!flag.is_cancelled());
311    }
312
313    #[test]
314    fn cancellable_read_fails_with_operation_cancelled_not_interrupted() {
315        let source = AtomicCancel::new();
316        let data = b"hello world";
317        let mut reader = CancellableRead::new(Cursor::new(&data[..]), CancelFlag::new(&source));
318        let mut buf = [0u8; 5];
319        assert_eq!(reader.read(&mut buf).expect("read"), 5);
320        source.cancel();
321        let err = reader.read(&mut buf).expect_err("cancelled");
322        assert_ne!(
323            err.kind(),
324            io::ErrorKind::Interrupted,
325            "Interrupted is retried by read_exact / pkt-line"
326        );
327        assert!(is_cancelled_io(&err));
328        assert_eq!(map_cancel_io(err), GitError::Cancelled);
329    }
330
331    #[test]
332    fn read_exact_does_not_spin_on_cancel() {
333        let source = AtomicCancel::new();
334        source.cancel();
335        let mut reader = CancellableRead::new(Cursor::new(&b"abcd"[..]), CancelFlag::new(&source));
336        let mut buf = [0u8; 4];
337        // Would spin forever if cancel used ErrorKind::Interrupted.
338        let err = reader.read_exact(&mut buf).expect_err("cancelled");
339        assert!(is_cancelled_io(&err));
340    }
341
342    #[test]
343    fn never_dyn_alias_matches_never() {
344        assert!(!CancelFlag::never().is_cancelled());
345    }
346
347    #[test]
348    fn kill_child_if_cancelled_is_noop_when_not_cancelled() {
349        let mut child = std::process::Command::new("sleep")
350            .arg("60")
351            .stdout(std::process::Stdio::null())
352            .stderr(std::process::Stdio::null())
353            .spawn()
354            .expect("spawn sleep");
355        let source = AtomicCancel::new();
356        kill_child_if_cancelled(&mut child, CancelFlag::new(&source));
357        assert!(child.try_wait().expect("try_wait").is_none());
358        let _ = child.kill();
359        let _ = child.wait();
360    }
361
362    #[test]
363    fn kill_child_if_cancelled_kills_when_flag_set() {
364        let mut child = std::process::Command::new("sleep")
365            .arg("60")
366            .stdout(std::process::Stdio::null())
367            .stderr(std::process::Stdio::null())
368            .spawn()
369            .expect("spawn sleep");
370        let source = AtomicCancel::new();
371        source.cancel();
372        kill_child_if_cancelled(&mut child, CancelFlag::new(&source));
373        let status = child.wait().expect("wait after kill");
374        assert!(!status.success());
375    }
376}