zipatch_rs/apply/cancel.rs
1//! Standalone cancellation primitive for apply-time aborts.
2//!
3//! [`CancelToken`] is a cheap, cloneable handle wrapping an
4//! [`AtomicBool`](std::sync::atomic::AtomicBool). One handle is held by the
5//! thread driving the apply ([`ApplyConfig::apply_patch`](crate::ApplyConfig::apply_patch)
6//! or [`IndexApplier::execute`](crate::IndexApplier::execute)) and one or
7//! more clones live wherever cancellation is initiated — typically a UI
8//! event handler or a `tokio::select!` arm.
9//!
10//! # Why a separate primitive
11//!
12//! Cancellation can also be signalled by an [`ApplyObserver`](crate::ApplyObserver)
13//! that returns `true` from [`ApplyObserver::should_cancel`](crate::ApplyObserver::should_cancel),
14//! but that route forces a consumer who only wants cancellation to implement
15//! the entire trait. The token makes the common case trivial:
16//!
17//! ```no_run
18//! use zipatch_rs::{ApplyConfig, CancelToken, open_patch};
19//!
20//! let token = CancelToken::new();
21//! let bg = token.clone();
22//! std::thread::spawn(move || {
23//! // Some other thread flips the flag on a user gesture.
24//! bg.cancel();
25//! });
26//!
27//! let reader = open_patch("patch.patch").unwrap();
28//! let _ = ApplyConfig::new("/opt/ffxiv/game")
29//! .with_cancel_token(token)
30//! .apply_patch(reader);
31//! ```
32//!
33//! When both a token and an observer are installed, the apply driver polls
34//! both — either source can request cancellation independently, and they
35//! compose without a wrapper observer.
36
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39
40/// Cloneable cancellation flag shared between the apply worker and any
41/// number of signalling threads.
42///
43/// `Clone` is `O(1)` — every handle points at the same underlying
44/// [`AtomicBool`]. Marking the token cancelled from any handle is visible to
45/// every other handle on the next [`Self::is_cancelled`] poll.
46///
47/// # Threading
48///
49/// `Send + Sync` is automatic: the inner [`Arc`] of [`AtomicBool`] is
50/// trivially shareable across threads. Construct on one thread, clone, and
51/// hand the clones to wherever they need to live.
52///
53/// # Cost
54///
55/// [`Self::is_cancelled`] is a single [`Ordering::Relaxed`] atomic load and
56/// is cheap enough to call from the inner SQPK block loop. [`Self::cancel`]
57/// is one relaxed store. The token allocates one [`Arc`]-managed
58/// [`AtomicBool`] at construction; clones share it.
59#[derive(Clone, Debug, Default)]
60pub struct CancelToken(Arc<AtomicBool>);
61
62impl CancelToken {
63 /// Construct a fresh, un-cancelled token.
64 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 /// Request cancellation.
70 ///
71 /// Idempotent — calling this on an already-cancelled token is a no-op.
72 /// The flip is immediately visible to every clone of this token.
73 pub fn cancel(&self) {
74 self.0.store(true, Ordering::Relaxed);
75 }
76
77 /// Returns `true` if [`Self::cancel`] has been called on any clone of
78 /// this token.
79 ///
80 /// Uses [`Ordering::Relaxed`] — the apply driver polls this in hot
81 /// loops and only needs eventual visibility, not synchronisation with
82 /// any other memory ops.
83 #[must_use]
84 pub fn is_cancelled(&self) -> bool {
85 self.0.load(Ordering::Relaxed)
86 }
87
88 /// Clear the cancellation flag.
89 ///
90 /// Useful for retry-after-cancel scenarios where the consumer wants to
91 /// reuse the same token (and therefore avoid re-wiring its clones)
92 /// across multiple apply attempts. The flip is immediately visible to
93 /// every clone.
94 pub fn reset(&self) {
95 self.0.store(false, Ordering::Relaxed);
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn new_token_is_not_cancelled() {
105 let t = CancelToken::new();
106 assert!(!t.is_cancelled());
107 }
108
109 #[test]
110 fn cancel_flips_the_flag() {
111 let t = CancelToken::new();
112 t.cancel();
113 assert!(t.is_cancelled());
114 }
115
116 #[test]
117 fn cancel_is_visible_across_clones() {
118 let a = CancelToken::new();
119 let b = a.clone();
120 assert!(!b.is_cancelled());
121 a.cancel();
122 assert!(
123 b.is_cancelled(),
124 "cancel on one handle must surface on the clone"
125 );
126 }
127
128 #[test]
129 fn cancel_is_idempotent() {
130 let t = CancelToken::new();
131 t.cancel();
132 t.cancel();
133 assert!(t.is_cancelled());
134 }
135
136 #[test]
137 fn reset_clears_the_flag() {
138 let t = CancelToken::new();
139 t.cancel();
140 assert!(t.is_cancelled());
141 t.reset();
142 assert!(!t.is_cancelled());
143 }
144
145 #[test]
146 fn reset_is_visible_across_clones() {
147 let a = CancelToken::new();
148 let b = a.clone();
149 a.cancel();
150 assert!(b.is_cancelled());
151 b.reset();
152 assert!(
153 !a.is_cancelled(),
154 "reset on one handle must surface on the clone"
155 );
156 }
157
158 #[test]
159 fn cancel_propagates_between_threads() {
160 let t = CancelToken::new();
161 let bg = t.clone();
162 let handle = std::thread::spawn(move || {
163 bg.cancel();
164 });
165 handle.join().unwrap();
166 assert!(t.is_cancelled());
167 }
168
169 #[test]
170 fn token_is_send_and_sync() {
171 fn assert_send_sync<T: Send + Sync>() {}
172 assert_send_sync::<CancelToken>();
173 }
174}