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}