lattice_mode/foreground_cancel.rs
1//! The foreground-cancellation seam, as a registered service (CG.2).
2//!
3//! Design: `docs/dev/architecture/cancellation.md`; sequencing:
4//! `docs/dev/operations/slice-plans/cancellation.md`.
5//!
6//! # Why a service rather than a method on `Editor`
7//!
8//! CG.1 put `arm_cancel()` on `Editor`, which needs `&mut`. That is
9//! reachable from a provider's *initial* trigger — `open_search_view`
10//! holds a `&mut dyn ModeActivator` — but not from the places that
11//! respawn the same work later. Project search's `gr` refresh is an
12//! `ActionHandlerRegistration` closure holding only `&self` services,
13//! and it spawns a replacement scan; LSP requests (CG.3) and plugin
14//! calls (CG.4) sit behind the same wall.
15//!
16//! Enrolling only where `&mut` happens to be available is the
17//! half-migration this project keeps re-discovering: `<C-g>` would
18//! cancel a *fresh* search and silently do nothing to a refreshed one,
19//! and nothing about the code would say so. So the arming surface takes
20//! `&self` and lives in the `ServiceRegistry`, where every subsystem
21//! already looks things up.
22//!
23//! # Contract
24//!
25//! One token armed at a time. [`ForegroundCancel::arm`] cancels its
26//! predecessor before handing out the new one, which means **supersede
27//! is the same mechanism as cancel** — a second `:search` before the
28//! first finishes abandons the first scan, and `gr` refreshing a view
29//! no longer needs a private flag to do it.
30//!
31//! v1 is deliberately single-slot: a second user-initiated op cancels
32//! whatever was running. `cancellation.md` §8 records why (and what a
33//! stack would change) — the user pressed cancel, or started something
34//! new, and either way the old work is unwanted.
35//!
36//! # Registration
37//!
38//! Register and look up under [`ForegroundCancelHandle`], never under
39//! `ForegroundCancel`. `ServiceRegistry::register::<T>` keys on
40//! `TypeId::of::<T>()`, so registering an `Arc<ForegroundCancel>` and
41//! asking for a `ForegroundCancel` silently returns `None`.
42
43use std::sync::{Arc, Mutex};
44
45use lattice_protocol::CancellationToken;
46
47/// The armed foreground token, shared between the host (which cancels)
48/// and every subsystem that spawns cancellable work (which arms).
49///
50/// `Mutex` rather than `ArcSwap` because arming is a compare-and-swap
51/// in spirit — cancel the old, install the new — and it happens once
52/// per user-initiated operation, not per keystroke or per frame. There
53/// is no hot-path read: the *token* is what gets polled in tight loops,
54/// and that is a plain atomic the caller already holds by then.
55#[derive(Debug, Default)]
56pub struct ForegroundCancel {
57 armed: Mutex<Option<CancellationToken>>,
58 enrolled: Mutex<Vec<CancellationToken>>,
59}
60
61/// Register and look up under this alias — see the module docs on the
62/// `TypeId` pitfall.
63pub type ForegroundCancelHandle = Arc<ForegroundCancel>;
64
65impl ForegroundCancel {
66 /// A clone of the currently-armed token, if any (CG.4).
67 ///
68 /// For callers that must poll cancellation from a context the arming
69 /// caller never reached — the plugin host's epoch callback, which
70 /// fires every millisecond inside a running guest call and cannot
71 /// take a lock there. It takes the lock ONCE per guest call and
72 /// polls the returned token (a plain atomic) thereafter, which is
73 /// the split this module's own docs describe: "the *token* is what
74 /// gets polled in tight loops".
75 ///
76 /// `None` when nothing is armed — no foreground operation is
77 /// running, so there is nothing for the caller to be cancelled by.
78 pub fn current_token(&self) -> Option<CancellationToken> {
79 match self.armed.lock() {
80 Ok(slot) => slot.clone(),
81 // Same posture as `arm`: losing cancellability for one
82 // operation beats taking the editor down.
83 Err(poisoned) => poisoned.into_inner().clone(),
84 }
85 }
86
87 /// Arm a fresh token for a user-initiated operation, cancelling any
88 /// predecessor, and hand back the clone the spawned task holds.
89 ///
90 /// Takes `&self` on purpose: the callers that need it most are
91 /// action-handler closures and event subscriptions, which never see
92 /// `&mut Editor`.
93 ///
94 /// A poisoned lock is treated as "nothing was armed" rather than a
95 /// panic — losing the ability to cancel one operation is a far
96 /// smaller failure than taking the editor down, and the fresh token
97 /// this returns is still valid for the caller about to spawn.
98 #[must_use = "the returned token must be handed to the spawned task, \
99 or the operation is unstoppable"]
100 pub fn arm(&self) -> CancellationToken {
101 let token = CancellationToken::new();
102 match self.armed.lock() {
103 Ok(mut slot) => {
104 if let Some(previous) = slot.take() {
105 previous.cancel();
106 }
107 *slot = Some(token.clone());
108 }
109 Err(poisoned) => {
110 tracing::warn!(
111 "foreground-cancel: lock poisoned; the previous operation \
112 cannot be cancelled, arming the new one anyway"
113 );
114 let mut slot = poisoned.into_inner();
115 if let Some(previous) = slot.take() {
116 previous.cancel();
117 }
118 *slot = Some(token.clone());
119 }
120 }
121 token
122 }
123
124 /// Join the foreground set **without** superseding anything (CG.3).
125 ///
126 /// For work that already has its own cancellation discipline and
127 /// only needs `<C-g>` to reach it. Every user-triggered LSP command
128 /// is like this: hover, rename, format and code-actions each hold a
129 /// per-feature token so a second hover supersedes the first, and
130 /// that is the right granularity — a hover has no business
131 /// cancelling a rename.
132 ///
133 /// [`arm`](Self::arm) would be wrong here. `K` is a reflexive
134 /// inspect key; making it supersede would mean glancing at a symbol
135 /// silently kills the project search you are waiting on, with a
136 /// half-populated buffer and nothing to say why.
137 ///
138 /// **Automatic requests must not come through here at all** —
139 /// completion, signature-help and the `maybe_request_*` family fire
140 /// on keystrokes, cursor moves and ticks. They are not
141 /// user-triggered, so by §3 of the design they are not foreground;
142 /// enrolling them would make `<C-g>` cancel whatever the editor
143 /// happened to be doing on its own behalf.
144 ///
145 /// Already-cancelled entries are pruned on each call, which is what
146 /// keeps the set from growing without bound across a session: a
147 /// completed request's token is never cancelled, so pruning cannot
148 /// rely on it — instead the set is small by construction (one live
149 /// request per feature) and every `cancel()` empties it.
150 pub fn enrol(&self, token: CancellationToken) {
151 let mut set = match self.enrolled.lock() {
152 Ok(set) => set,
153 Err(poisoned) => poisoned.into_inner(),
154 };
155 set.retain(|t| !t.is_cancelled());
156 set.push(token);
157 }
158
159 /// Cancel everything foreground: the armed slot **and** every
160 /// enrolled token. Clears both.
161 ///
162 /// Idempotent — cancelling nothing is the common case, since the
163 /// binding is pressed far more often than an operation is running.
164 pub fn cancel(&self) {
165 let taken = match self.armed.lock() {
166 Ok(mut slot) => slot.take(),
167 Err(poisoned) => poisoned.into_inner().take(),
168 };
169 if let Some(token) = taken {
170 token.cancel();
171 }
172 let enrolled = match self.enrolled.lock() {
173 Ok(mut set) => std::mem::take(&mut *set),
174 Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
175 };
176 for token in enrolled {
177 token.cancel();
178 }
179 }
180
181 /// How many tokens are currently enrolled. For tests and for a
182 /// future status indicator (`cancellation.md` §7).
183 pub fn enrolled_len(&self) -> usize {
184 match self.enrolled.lock() {
185 Ok(set) => set.len(),
186 Err(poisoned) => poisoned.into_inner().len(),
187 }
188 }
189
190 /// Whether an operation is currently armed. For tests and for a
191 /// future status indicator (`cancellation.md` §7).
192 pub fn is_armed(&self) -> bool {
193 match self.armed.lock() {
194 Ok(slot) => slot.is_some(),
195 Err(poisoned) => poisoned.into_inner().is_some(),
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 #![allow(clippy::unwrap_used, clippy::panic)]
203 use super::*;
204
205 /// The predecessor must die when a new op arms. Without this, a
206 /// second `:search` before the first completes leaves two scans
207 /// racing to write the same view.
208 #[test]
209 fn arming_cancels_the_previous_operation() {
210 let fc = ForegroundCancel::default();
211 let first = fc.arm();
212 let second = fc.arm();
213
214 assert!(first.is_cancelled(), "the predecessor must be cancelled");
215 assert!(!second.is_cancelled(), "the new token starts live");
216 assert!(fc.is_armed());
217 }
218
219 #[test]
220 fn cancel_flips_and_clears() {
221 let fc = ForegroundCancel::default();
222 let token = fc.arm();
223
224 fc.cancel();
225
226 assert!(token.is_cancelled());
227 assert!(
228 !fc.is_armed(),
229 "a cancelled token must not stay armed, or the next arm() \
230 would 'cancel' it a second time"
231 );
232 }
233
234 /// The binding is pressed far more often than work is running.
235 #[test]
236 fn cancelling_when_idle_is_a_noop() {
237 let fc = ForegroundCancel::default();
238 fc.cancel();
239 fc.cancel();
240 assert!(!fc.is_armed());
241 }
242
243 /// Supersede and cancel are the SAME mechanism — this is what lets
244 /// project search drop its private `AtomicBool` rather than run a
245 /// second, parallel scheme that `<C-g>` would not reach.
246 #[test]
247 fn a_superseded_scan_and_a_cancelled_one_observe_the_same_flag() {
248 let fc = ForegroundCancel::default();
249 let scan = fc.arm();
250
251 // `gr` refresh: arming the replacement supersedes the first.
252 let refreshed = fc.arm();
253 assert!(scan.is_cancelled());
254
255 // `<C-g>` then reaches the REPLACEMENT, not just the original.
256 fc.cancel();
257 assert!(refreshed.is_cancelled());
258 }
259
260 /// CG.3: the distinction the whole slice turns on. `K` during a
261 /// project search must not kill the search.
262 #[test]
263 fn enrolling_does_not_supersede_the_armed_operation() {
264 let fc = ForegroundCancel::default();
265 let search = fc.arm();
266
267 let hover = CancellationToken::new();
268 fc.enrol(hover.clone());
269
270 assert!(
271 !search.is_cancelled(),
272 "a reflexive `K` must not cancel the search the user is \
273 waiting on — that is why LSP commands enrol rather than arm"
274 );
275 assert!(!hover.is_cancelled());
276 }
277
278 /// …and `<C-g>` still reaches both.
279 #[test]
280 fn cancel_reaches_the_armed_slot_and_every_enrolled_token() {
281 let fc = ForegroundCancel::default();
282 let search = fc.arm();
283 let hover = CancellationToken::new();
284 let rename = CancellationToken::new();
285 fc.enrol(hover.clone());
286 fc.enrol(rename.clone());
287
288 fc.cancel();
289
290 assert!(search.is_cancelled());
291 assert!(hover.is_cancelled());
292 assert!(rename.is_cancelled());
293 }
294
295 /// Enrolled tokens are independent of each other — a rename does
296 /// not stop a hover. Per-feature supersede stays the LSP layer's
297 /// job, at the granularity it already gets right.
298 #[test]
299 fn enrolled_tokens_do_not_cancel_each_other() {
300 let fc = ForegroundCancel::default();
301 let hover = CancellationToken::new();
302 fc.enrol(hover.clone());
303 fc.enrol(CancellationToken::new());
304
305 assert!(!hover.is_cancelled());
306 }
307
308 /// The set must not grow without bound across a session. Cancelled
309 /// entries are pruned as new ones arrive.
310 #[test]
311 fn cancelled_entries_are_pruned_on_enrol() {
312 let fc = ForegroundCancel::default();
313 for _ in 0..100 {
314 let t = CancellationToken::new();
315 fc.enrol(t.clone());
316 t.cancel();
317 }
318 let live = CancellationToken::new();
319 fc.enrol(live.clone());
320 assert_eq!(fc.enrolled_len(), 1, "only the live token should remain");
321 }
322
323 /// Shared through an `Arc` across threads, which is how the host
324 /// and a `spawn_blocking` scan actually hold it.
325 #[test]
326 fn the_handle_is_shareable_across_threads() {
327 let fc: ForegroundCancelHandle = Arc::new(ForegroundCancel::default());
328 let token = fc.arm();
329 let other = Arc::clone(&fc);
330 std::thread::spawn(move || other.cancel())
331 .join()
332 .expect("canceller thread");
333 assert!(token.is_cancelled());
334 }
335}