Skip to main content

lattice_mode/
idle_gate.rs

1//! Idle-gate registry — the generic armed-deadline primitive (WK.3).
2//!
3//! A subsystem registers a handler and an *armed deadline*; when the
4//! deadline elapses the editor actor runs the handler and applies the
5//! `Effect`s it returns. Which-key's "hold a prefix for 300 ms" is the
6//! first consumer; "did you mean…" hints, idle-time prefetch and the
7//! inline-diagnostic gate are the obvious others.
8//!
9//! ## Why a registry rather than a field
10//!
11//! This is the third instance of a shape this codebase has twice decided
12//! is right. [`tick_callback`](crate::tick_callback)'s module doc names
13//! the alternative as the smell it exists to kill:
14//!
15//! > rather than adding an `Editor::drain_<x>` method + an
16//! > `Option<Receiver>` field per subsystem, a mode owns its channel and
17//! > registers a closure that drains it.
18//!
19//! A deadline field per subsystem is that same smell in the time domain,
20//! and `Editor::inline_diag_deadline` is the existing instance of it —
21//! a bespoke `Option<Instant>` on the editor plus a hand-written
22//! `select!` arm in the actor. One more subsystem wanting a delay would
23//! mean a second field and a second arm.
24//!
25//! The inline-diagnostic gate is deliberately NOT migrated here (design
26//! §9): its arm decision runs inside `publish_render_state`, reading
27//! `config`, `modal` and `cursor.line`, and there is no cursor-moved
28//! typed event to subscribe to. Finishing that migration means
29//! publishing a `CursorSettled` event plus surgery on working code, and
30//! gating a discoverability feature behind an LSP refactor is backwards.
31//! This registry runs beside it.
32//!
33//! ## Timing
34//!
35//! The registry stores deadlines only; it owns no timer. The actor asks
36//! for [`earliest`](IdleGateRegistry::earliest) once per loop iteration
37//! and points its single pinned sleep there, then calls
38//! [`fire_elapsed`](IdleGateRegistry::fire_elapsed) when it wakes. That
39//! keeps every `tokio` concern in the actor and leaves this unit
40//! testable with a plain clock.
41//!
42//! ## Lifecycle
43//!
44//! [`register`](IdleGateRegistry::register) returns an RAII
45//! [`IdleGateHandle`], mirroring `TickCallbackRegistration`: a mode
46//! aggregates the handle into its `Guard`, so a deactivated subsystem
47//! contributes no timer at all.
48
49use std::sync::Arc;
50use std::sync::Mutex;
51
52use lattice_grammar::effect::Effect;
53use tokio::time::Instant;
54
55/// A gate's body. Runs on the editor actor thread when the gate's
56/// deadline elapses; returns the `Effect`s the host applies.
57///
58/// `FnMut` for the same reason a tick callback is: the canonical body
59/// reads state the subsystem stashed when it armed.
60pub type IdleGateHandler = Box<dyn FnMut() -> Vec<Effect> + Send + 'static>;
61
62/// Typed handle for `ServiceRegistry` lookup. Per the Arc/TypeId rule,
63/// register and look up with the same `T`; this alias guarantees it.
64pub type IdleGateRegistryHandle = Arc<IdleGateRegistry>;
65
66struct Gate {
67    id: u64,
68    /// Human-readable, for `debug!` lines when a gate fires.
69    name: &'static str,
70    /// `None` = disarmed. A disarmed gate costs nothing: it never
71    /// contributes to `earliest`, so the actor's sleep stays parked.
72    deadline: Option<Instant>,
73    handler: IdleGateHandler,
74}
75
76struct Inner {
77    next_id: u64,
78    gates: Vec<Gate>,
79}
80
81/// Registry of subsystem-contributed idle gates.
82pub struct IdleGateRegistry {
83    inner: Mutex<Inner>,
84}
85
86impl IdleGateRegistry {
87    /// An empty registry. The host builds one and registers it as an
88    /// [`IdleGateRegistryHandle`].
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use std::sync::Arc;
94    /// use std::time::Duration;
95    /// use lattice_mode::idle_gate::IdleGateRegistry;
96    /// use tokio::time::Instant;
97    ///
98    /// let gates = Arc::new(IdleGateRegistry::new());
99    /// let gate = gates.register("hint", Box::new(Vec::new));
100    /// assert_eq!(gates.earliest(), None); // registered disarmed
101    ///
102    /// let t0 = Instant::now();
103    /// gate.arm(t0 + Duration::from_millis(300));
104    /// assert!(gates.fire_elapsed(t0).is_empty()); // not yet due
105    /// gates.fire_elapsed(t0 + Duration::from_millis(300)); // fires, then disarms
106    /// assert_eq!(gates.earliest(), None);
107    ///
108    /// drop(gate); // the Guard dropping removes the gate
109    /// ```
110    pub fn new() -> Self {
111        Self {
112            inner: Mutex::new(Inner {
113                next_id: 0,
114                gates: Vec::new(),
115            }),
116        }
117    }
118
119    /// Register a gate, disarmed. Returns an RAII handle; dropping it
120    /// removes the gate.
121    pub fn register(
122        self: &Arc<Self>,
123        name: &'static str,
124        handler: IdleGateHandler,
125    ) -> IdleGateHandle {
126        let id = {
127            let mut g = self.lock();
128            let id = g.next_id;
129            g.next_id += 1;
130            g.gates.push(Gate {
131                id,
132                name,
133                deadline: None,
134                handler,
135            });
136            id
137        };
138        IdleGateHandle {
139            registry: Arc::clone(self),
140            id,
141        }
142    }
143
144    /// The earliest armed deadline, or `None` when every gate is
145    /// disarmed. The actor points its pinned sleep here; `None` means
146    /// park it far out and let the guard keep the arm dormant.
147    pub fn earliest(&self) -> Option<Instant> {
148        self.lock().gates.iter().filter_map(|g| g.deadline).min()
149    }
150
151    /// Run every gate whose deadline is at or before `now`, disarming
152    /// each as it fires, and return the concatenated `Effect`s.
153    ///
154    /// Disarm-then-run is deliberate: a gate that re-arms itself from
155    /// inside its own handler must be able to, and it cannot if firing
156    /// clears the deadline afterwards.
157    pub fn fire_elapsed(&self, now: Instant) -> Vec<Effect> {
158        let mut g = self.lock();
159        let mut effects = Vec::new();
160        for gate in g.gates.iter_mut() {
161            if gate.deadline.is_some_and(|d| d <= now) {
162                gate.deadline = None;
163                tracing::debug!(gate = gate.name, "idle gate fired");
164                effects.extend((gate.handler)());
165            }
166        }
167        effects
168    }
169
170    fn arm(&self, id: u64, at: Instant) {
171        if let Some(gate) = self.lock().gates.iter_mut().find(|g| g.id == id) {
172            gate.deadline = Some(at);
173        }
174    }
175
176    fn disarm(&self, id: u64) {
177        if let Some(gate) = self.lock().gates.iter_mut().find(|g| g.id == id) {
178            gate.deadline = None;
179        }
180    }
181
182    fn unregister(&self, id: u64) {
183        self.lock().gates.retain(|g| g.id != id);
184    }
185
186    /// Number of registered gates. Test affordance.
187    #[doc(hidden)]
188    pub fn registered_count(&self) -> usize {
189        self.lock().gates.len()
190    }
191
192    /// Poison recovery, for the same reason `TickCallbackRegistry` does
193    /// it: one panicking handler must not wedge every other subsystem's
194    /// gate.
195    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
196        self.inner.lock().unwrap_or_else(|e| e.into_inner())
197    }
198}
199
200impl Default for IdleGateRegistry {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206impl std::fmt::Debug for IdleGateRegistry {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        f.debug_struct("IdleGateRegistry")
209            .field("registered_count", &self.registered_count())
210            .finish_non_exhaustive()
211    }
212}
213
214/// RAII handle: arms and disarms one gate, and removes it on drop.
215///
216/// `Send + 'static` so it fits the `Mode::Guard: Send + 'static` bound.
217pub struct IdleGateHandle {
218    registry: Arc<IdleGateRegistry>,
219    id: u64,
220}
221
222impl IdleGateHandle {
223    /// Arm (or re-arm) this gate to fire at `at`. Re-arming an armed
224    /// gate replaces its deadline — which is what makes a *growing*
225    /// prefix pay the delay once per chord rather than once per key.
226    pub fn arm(&self, at: Instant) {
227        self.registry.arm(self.id, at);
228    }
229
230    /// Cancel a pending fire. Idempotent.
231    pub fn disarm(&self) {
232        self.registry.disarm(self.id);
233    }
234}
235
236impl Drop for IdleGateHandle {
237    fn drop(&mut self) {
238        self.registry.unregister(self.id);
239    }
240}
241
242impl std::fmt::Debug for IdleGateHandle {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("IdleGateHandle")
245            .field("id", &self.id)
246            .finish_non_exhaustive()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::sync::atomic::{AtomicUsize, Ordering};
254    use std::time::Duration;
255
256    fn counting_gate(count: Arc<AtomicUsize>) -> IdleGateHandler {
257        Box::new(move || {
258            count.fetch_add(1, Ordering::SeqCst);
259            Vec::new()
260        })
261    }
262
263    #[tokio::test]
264    async fn an_unarmed_registry_parks_the_actor_sleep() {
265        let r = Arc::new(IdleGateRegistry::new());
266        let _h = r.register("test", counting_gate(Arc::new(AtomicUsize::new(0))));
267        assert!(
268            r.earliest().is_none(),
269            "a registered-but-disarmed gate contributes no deadline"
270        );
271    }
272
273    #[tokio::test]
274    async fn the_earlier_of_two_armed_gates_is_the_one_the_actor_sleeps_to() {
275        let r = Arc::new(IdleGateRegistry::new());
276        let early_count = Arc::new(AtomicUsize::new(0));
277        let late_count = Arc::new(AtomicUsize::new(0));
278        let early = r.register("early", counting_gate(Arc::clone(&early_count)));
279        let late = r.register("late", counting_gate(Arc::clone(&late_count)));
280
281        let now = Instant::now();
282        late.arm(now + Duration::from_millis(500));
283        early.arm(now + Duration::from_millis(100));
284        assert_eq!(
285            r.earliest(),
286            Some(now + Duration::from_millis(100)),
287            "the minimum across gates, not registration order"
288        );
289
290        // Fire at a moment past the early deadline only.
291        r.fire_elapsed(now + Duration::from_millis(200));
292        assert_eq!(early_count.load(Ordering::SeqCst), 1);
293        assert_eq!(late_count.load(Ordering::SeqCst), 0, "not yet due");
294        assert_eq!(
295            r.earliest(),
296            Some(now + Duration::from_millis(500)),
297            "a fired gate disarms itself; the later one remains"
298        );
299
300        r.fire_elapsed(now + Duration::from_millis(600));
301        assert_eq!(late_count.load(Ordering::SeqCst), 1);
302        assert!(r.earliest().is_none(), "both fired and disarmed");
303    }
304
305    #[tokio::test]
306    async fn a_fired_gate_does_not_fire_again() {
307        let r = Arc::new(IdleGateRegistry::new());
308        let count = Arc::new(AtomicUsize::new(0));
309        let h = r.register("once", counting_gate(Arc::clone(&count)));
310        let now = Instant::now();
311        h.arm(now);
312        r.fire_elapsed(now);
313        r.fire_elapsed(now + Duration::from_secs(1));
314        assert_eq!(
315            count.load(Ordering::SeqCst),
316            1,
317            "firing disarms — otherwise a popup would reopen every loop \
318             iteration forever"
319        );
320    }
321
322    #[tokio::test]
323    async fn disarm_cancels_a_pending_fire() {
324        let r = Arc::new(IdleGateRegistry::new());
325        let count = Arc::new(AtomicUsize::new(0));
326        let h = r.register("cancelled", counting_gate(Arc::clone(&count)));
327        let now = Instant::now();
328        h.arm(now + Duration::from_millis(50));
329        h.disarm();
330        assert!(r.earliest().is_none());
331        r.fire_elapsed(now + Duration::from_secs(1));
332        assert_eq!(
333            count.load(Ordering::SeqCst),
334            0,
335            "the chord resolved before the delay elapsed — no popup"
336        );
337    }
338
339    #[tokio::test]
340    async fn re_arming_replaces_the_deadline() {
341        let r = Arc::new(IdleGateRegistry::new());
342        let h = r.register("regrow", counting_gate(Arc::new(AtomicUsize::new(0))));
343        let now = Instant::now();
344        h.arm(now + Duration::from_millis(100));
345        h.arm(now + Duration::from_millis(300));
346        assert_eq!(
347            r.earliest(),
348            Some(now + Duration::from_millis(300)),
349            "re-arm replaces rather than adding a second deadline"
350        );
351    }
352
353    #[tokio::test]
354    async fn dropping_the_handle_deregisters_the_gate() {
355        let r = Arc::new(IdleGateRegistry::new());
356        let count = Arc::new(AtomicUsize::new(0));
357        let now = Instant::now();
358        {
359            let h = r.register("scoped", counting_gate(Arc::clone(&count)));
360            h.arm(now);
361            assert_eq!(r.registered_count(), 1);
362        }
363        assert_eq!(
364            r.registered_count(),
365            0,
366            "a deactivated subsystem contributes no timer"
367        );
368        r.fire_elapsed(now + Duration::from_secs(1));
369        assert_eq!(count.load(Ordering::SeqCst), 0);
370    }
371
372    #[tokio::test]
373    async fn only_due_gates_fire_when_several_are_armed() {
374        let r = Arc::new(IdleGateRegistry::new());
375        let now = Instant::now();
376        let counts: Vec<Arc<AtomicUsize>> = (0..3).map(|_| Arc::new(AtomicUsize::new(0))).collect();
377        let handles: Vec<IdleGateHandle> = counts
378            .iter()
379            .enumerate()
380            .map(|(i, c)| {
381                let h = r.register("multi", counting_gate(Arc::clone(c)));
382                h.arm(now + Duration::from_millis(100 * (i as u64 + 1)));
383                h
384            })
385            .collect();
386
387        r.fire_elapsed(now + Duration::from_millis(250));
388        assert_eq!(
389            counts
390                .iter()
391                .map(|c| c.load(Ordering::SeqCst))
392                .collect::<Vec<_>>(),
393            vec![1, 1, 0],
394            "the 100ms and 200ms gates fired; the 300ms one is still armed"
395        );
396        drop(handles);
397    }
398}