Skip to main content

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}