Skip to main content

lattice_mode/
tick_callback.rs

1//! IDE-protocol I1.1: tick-callback registry — the one generic host
2//! primitive the Claude Code IDE peer needs.
3//!
4//! A mode registers a per-tick drain closure; the host runs every
5//! registered closure once per editor tick (inside `run_tick_pending`)
6//! and applies the `Effect`s they return through the existing effect
7//! pipeline. This *generalizes* the host's existing hardcoded per-tick
8//! drains (`option_change_rx`, `lsp_log_event_rx`, …) — those are the
9//! smell this primitive replaces for new subsystems: rather than adding
10//! an `Editor::drain_<x>` method + an `Option<Receiver>` field per
11//! subsystem, a mode owns its channel and registers a closure that
12//! drains it.
13//!
14//! Per `feedback_mode_owns_its_surface`: the drain *body* lives in the
15//! mode's owning crate (it closes over the mode's own receiver), not in
16//! `lattice-host`. The host's role is the generic run-loop + the
17//! effect-apply pipeline.
18//!
19//! ## Shape
20//!
21//! Unlike [`ActionHandlerRegistry`](crate::action_handler_registry::ActionHandlerRegistry)
22//! — whose `Fn` handlers are wait-free behind `arc-swap` — a tick
23//! callback is `FnMut`: it mutates captured state (a channel receiver)
24//! on every run. So the registry stores the closures behind a
25//! `Mutex<Vec<…>>` rather than an `ArcSwap`. Registration is rare (per
26//! mode activation / deactivation); `run_all` is the per-tick hot path,
27//! but it runs on the editor (actor) thread only — the lock is
28//! uncontended in practice.
29//!
30//! ## Lifecycle
31//!
32//! [`register`](TickCallbackRegistry::register) returns a
33//! [`TickCallbackRegistration`] RAII token. A mode's `Guard` carries the
34//! token; dropping the Guard on deactivation drops the token, which
35//! removes the closure — so a stopped mode contributes no per-tick work.
36
37use std::sync::Arc;
38use std::sync::Mutex;
39
40use lattice_grammar::effect::Effect;
41
42/// A per-tick drain closure. Returns the `Effect`s the host should apply
43/// this tick (empty when there was nothing pending). `FnMut` because the
44/// canonical body drains a channel receiver, which needs `&mut`.
45///
46/// `Send` so the registry (held behind an `Arc` shared with mode
47/// activation paths) is `Send`; the closure is only ever *invoked* on
48/// the editor thread inside [`TickCallbackRegistry::run_all`].
49pub type TickCallback = Box<dyn FnMut() -> Vec<Effect> + Send + 'static>;
50
51/// Typed handle for `ServiceRegistry` lookup. Boot registers a fresh
52/// `TickCallbackRegistry` under this alias; modes pull it from
53/// `on_activate` via `ctx.service::<TickCallbackRegistryHandle>()` (or
54/// receive it directly at registration) and add their drain.
55///
56/// Per `feedback_servicesregistry_arc_typeid`: register and lookup MUST
57/// use the same `T` for the TypeId hash to match. This alias guarantees
58/// the convention.
59pub type TickCallbackRegistryHandle = Arc<TickCallbackRegistry>;
60
61/// Internal mutable state: a monotonic id counter plus the live
62/// callbacks. Kept in one `Mutex` so `register` is a single critical
63/// section.
64struct Inner {
65    next_id: u64,
66    callbacks: Vec<(u64, TickCallback)>,
67}
68
69/// Registry of mode-contributed per-tick drain closures.
70///
71/// Stored behind an `Arc` and shared by reference: the host calls
72/// [`run_all`](Self::run_all) once per tick; modes
73/// [`register`](Self::register) during `Mode::on_activate` and
74/// unregister via the returned [`TickCallbackRegistration`] token's
75/// `Drop`.
76pub struct TickCallbackRegistry {
77    inner: Mutex<Inner>,
78}
79
80impl TickCallbackRegistry {
81    /// Construct an empty registry.
82    pub fn new() -> Self {
83        Self {
84            inner: Mutex::new(Inner {
85                next_id: 0,
86                callbacks: Vec::new(),
87            }),
88        }
89    }
90
91    /// Register a per-tick drain closure. Returns an RAII registration
92    /// token; dropping it removes the closure. Modes accumulate the
93    /// token in their `Guard` so deactivation drops it and the per-tick
94    /// drain stops naturally.
95    pub fn register(self: &Arc<Self>, callback: TickCallback) -> TickCallbackRegistration {
96        let id = {
97            // Poison recovery: a panicking callback while the lock is
98            // held (see `run_all`) would poison the Mutex; we never want
99            // a single bad drain to wedge every other mode's drain, so
100            // recover the inner state rather than propagate the panic.
101            let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
102            let id = g.next_id;
103            g.next_id += 1;
104            g.callbacks.push((id, callback));
105            id
106        };
107        TickCallbackRegistration {
108            registry: Arc::clone(self),
109            id,
110        }
111    }
112
113    /// Run every registered callback once, in registration order, and
114    /// return the concatenated `Effect`s for the host to apply. Called
115    /// once per editor tick from `Editor::run_tick_pending`.
116    ///
117    /// Holds the lock for the duration: a callback MUST NOT call
118    /// `register` / drop a [`TickCallbackRegistration`] on *this same*
119    /// registry from inside its body (it would deadlock). Drain
120    /// closures only ever touch their own channel + return effects, so
121    /// this is a documented non-constraint in practice.
122    pub fn run_all(&self) -> Vec<Effect> {
123        let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
124        let mut effects = Vec::new();
125        for (_id, cb) in g.callbacks.iter_mut() {
126            effects.extend(cb());
127        }
128        effects
129    }
130
131    /// Direct removal by id. Normally called via
132    /// [`TickCallbackRegistration::drop`].
133    fn unregister(&self, id: u64) {
134        let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
135        g.callbacks.retain(|(cid, _)| *cid != id);
136    }
137
138    /// Number of currently registered callbacks. Test affordance.
139    #[doc(hidden)]
140    pub fn registered_count(&self) -> usize {
141        self.inner
142            .lock()
143            .unwrap_or_else(|e| e.into_inner())
144            .callbacks
145            .len()
146    }
147}
148
149impl Default for TickCallbackRegistry {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl std::fmt::Debug for TickCallbackRegistry {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("TickCallbackRegistry")
158            .field("registered_count", &self.registered_count())
159            .finish_non_exhaustive()
160    }
161}
162
163/// RAII registration token. Dropping it removes the callback. Modes
164/// aggregate the token into their `Mode::Guard`; when the Guard drops on
165/// deactivation, the callback is removed and the mode contributes no
166/// further per-tick work.
167///
168/// `Send + 'static` so it fits the `Mode::Guard: Send + 'static` bound.
169pub struct TickCallbackRegistration {
170    registry: Arc<TickCallbackRegistry>,
171    id: u64,
172}
173
174impl Drop for TickCallbackRegistration {
175    fn drop(&mut self) {
176        self.registry.unregister(self.id);
177    }
178}
179
180impl std::fmt::Debug for TickCallbackRegistration {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("TickCallbackRegistration")
183            .field("id", &self.id)
184            .finish_non_exhaustive()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    #![allow(clippy::unwrap_used)]
191    use super::*;
192    use std::sync::atomic::{AtomicUsize, Ordering};
193
194    #[test]
195    fn run_all_on_empty_registry_returns_no_effects() {
196        let r = Arc::new(TickCallbackRegistry::new());
197        assert!(r.run_all().is_empty());
198    }
199
200    #[test]
201    fn registered_callback_runs_and_returns_effects() {
202        let r = Arc::new(TickCallbackRegistry::new());
203        let _reg = r.register(Box::new(|| vec![Effect::None]));
204        let effects = r.run_all();
205        assert_eq!(effects.len(), 1);
206        assert!(matches!(effects[0], Effect::None));
207    }
208
209    #[test]
210    fn callback_is_fnmut_and_observes_state_across_ticks() {
211        // The canonical body mutates captured state every run (a
212        // receiver drain). Prove `FnMut` semantics: a captured counter
213        // increments on each `run_all`.
214        let r = Arc::new(TickCallbackRegistry::new());
215        let runs = Arc::new(AtomicUsize::new(0));
216        let runs_in = Arc::clone(&runs);
217        let _reg = r.register(Box::new(move || {
218            runs_in.fetch_add(1, Ordering::SeqCst);
219            Vec::new()
220        }));
221        r.run_all();
222        r.run_all();
223        r.run_all();
224        assert_eq!(runs.load(Ordering::SeqCst), 3);
225    }
226
227    #[test]
228    fn multiple_callbacks_all_run_in_registration_order() {
229        let r = Arc::new(TickCallbackRegistry::new());
230        let _a = r.register(Box::new(|| vec![Effect::None]));
231        let _b = r.register(Box::new(|| vec![Effect::None, Effect::None]));
232        let effects = r.run_all();
233        // a's one + b's two, concatenated.
234        assert_eq!(effects.len(), 3);
235    }
236
237    #[test]
238    fn drop_registration_stops_the_callback() {
239        let r = Arc::new(TickCallbackRegistry::new());
240        let reg = r.register(Box::new(|| vec![Effect::None]));
241        assert_eq!(r.registered_count(), 1);
242        assert_eq!(r.run_all().len(), 1);
243        drop(reg);
244        assert_eq!(r.registered_count(), 0);
245        assert!(r.run_all().is_empty());
246    }
247
248    #[test]
249    fn registrations_drop_independently() {
250        let r = Arc::new(TickCallbackRegistry::new());
251        let reg_a = r.register(Box::new(|| vec![Effect::None]));
252        let _reg_b = r.register(Box::new(|| vec![Effect::None]));
253        assert_eq!(r.registered_count(), 2);
254        drop(reg_a);
255        assert_eq!(r.registered_count(), 1);
256        // The surviving callback still runs.
257        assert_eq!(r.run_all().len(), 1);
258    }
259
260    #[test]
261    fn registration_is_send_static() {
262        fn assert_send_static<T: Send + 'static>() {}
263        assert_send_static::<TickCallbackRegistration>();
264    }
265
266    #[test]
267    fn registry_is_send_sync() {
268        fn assert_send_sync<T: Send + Sync>() {}
269        assert_send_sync::<TickCallbackRegistry>();
270    }
271}