lattice_mode/guards.rs
1//! `GuardStore`: type-erased storage for per-`(buffer, mode)`
2//! Guards returned by [`Mode::on_activate`](crate::Mode::on_activate).
3//!
4//! The dispatcher stashes each successful activation's Guard in
5//! this store, keyed by `(BufferId, ModeId)`. On deactivation,
6//! the dispatcher removes the entry and drops the boxed Guard;
7//! the Guard's `Drop` impl performs cleanup (unsubscribe,
8//! restore prior option value, drop supervisor handle, ...).
9//!
10//! Storage is `Box<dyn Any + Send>` because each mode's Guard
11//! type is different. The dispatcher never downcasts -- dropping
12//! through the `dyn Any` trait object correctly invokes the
13//! original type's `Drop` via the vtable.
14//!
15//! M-async.2: the store is accessed from two threads -- the App
16//! thread (synchronous deactivate path; activation's sync
17//! prefix) and the tokio worker that runs the spawned lifecycle
18//! future (inserts the Guard when `on_activate` resolves). The
19//! [`GuardStoreHandle`] wraps the store in `Arc<Mutex<...>>` so
20//! both threads can lock briefly without `&mut` lifetime
21//! gymnastics. The App owns one handle; the dispatcher clones
22//! it into each spawned task.
23
24use std::any::Any;
25use std::collections::HashMap;
26use std::sync::{Arc, Mutex};
27
28use lattice_protocol::ids::BufferId;
29
30use crate::mode::ModeId;
31
32/// Type-erased per-`(buffer, mode)` Guard storage.
33///
34/// One per App, and in practice always behind a [`GuardStoreHandle`]
35/// (the registry's activation / deactivation methods take the handle, so
36/// the spawned lifecycle task can insert into the same store). Default is
37/// empty.
38///
39/// Not `Clone` -- `Box<dyn Any>` is not `Clone`.
40///
41/// **M-async.4 epoch counter:** each `(buffer, mode)` key
42/// carries a `u64` epoch that monotonically increments on every
43/// activate begin + every deactivate. The dispatcher's spawn
44/// task captures the epoch when it queues, then validates
45/// against the current epoch via [`Self::try_insert`] before
46/// stashing its Guard. A mismatch means a deactivate (or a
47/// later activate) arrived while the spawn was in flight; the
48/// returned `Err(stale_guard)` lets the spawn drop the Guard
49/// (firing its Drop for out-of-band cleanup) instead of
50/// stashing it in a logically-inactive store slot.
51#[derive(Default)]
52pub struct GuardStore {
53 map: HashMap<(BufferId, ModeId), Box<dyn Any + Send>>,
54 epochs: HashMap<(BufferId, ModeId), u64>,
55}
56
57impl std::fmt::Debug for GuardStore {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("GuardStore")
60 .field("count", &self.map.len())
61 .finish_non_exhaustive()
62 }
63}
64
65impl GuardStore {
66 /// Empty store.
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 /// Stash a Guard for the given `(buffer, mode)`. Replaces
72 /// any existing entry (the old Guard is dropped, firing its
73 /// `Drop` impl). Used in tests + the rare reload path; the
74 /// production dispatcher routes through [`Self::try_insert`]
75 /// to respect the epoch invariant.
76 pub fn insert(&mut self, buffer: BufferId, mode: ModeId, guard: Box<dyn Any + Send>) {
77 self.map.insert((buffer, mode), guard);
78 }
79
80 /// Bump the epoch for `(buffer, mode)` and return the new
81 /// value. The dispatcher's sync prefix calls this when
82 /// queueing a step; the spawn task captures the returned
83 /// value and passes it to [`Self::try_insert`] on
84 /// completion. Wraps on overflow (`u64::MAX → 0`); the
85 /// dispatcher tolerates this because consecutive bumps
86 /// always advance by 1, so a wrap that happens to land on
87 /// a stale spawn's captured epoch would require 2^64
88 /// activate / deactivate cycles in flight -- not a
89 /// realistic concern.
90 pub fn bump_epoch(&mut self, buffer: BufferId, mode: ModeId) -> u64 {
91 let entry = self.epochs.entry((buffer, mode)).or_insert(0);
92 *entry = entry.wrapping_add(1);
93 *entry
94 }
95
96 /// Current epoch for `(buffer, mode)`. `0` if the pair has
97 /// never had an activation queued. Used by tests + the
98 /// dispatcher's spawn task to validate before stashing.
99 pub fn current_epoch(&self, buffer: BufferId, mode: ModeId) -> u64 {
100 self.epochs.get(&(buffer, mode)).copied().unwrap_or(0)
101 }
102
103 /// Insert `guard` only if `my_epoch` still matches the
104 /// store's current epoch for `(buffer, mode)`. Returns
105 /// `Ok(())` on success; on epoch mismatch returns
106 /// `Err(guard)` so the caller can drop the Guard outside
107 /// the lock (the Box's `Drop` then fires the original
108 /// type's cleanup).
109 ///
110 /// Used by the M-async.4 spawn-task path: a deactivate
111 /// (or a subsequent activate) arriving while a spawn was
112 /// in flight bumps the epoch via [`Self::remove`] /
113 /// [`Self::bump_epoch`]; the spawn's late `try_insert`
114 /// then fails the match and drops the Guard instead of
115 /// stashing it in a logically-inactive store slot.
116 pub fn try_insert(
117 &mut self,
118 buffer: BufferId,
119 mode: ModeId,
120 my_epoch: u64,
121 guard: Box<dyn Any + Send>,
122 ) -> Result<(), Box<dyn Any + Send>> {
123 if self.current_epoch(buffer, mode) == my_epoch {
124 self.map.insert((buffer, mode), guard);
125 Ok(())
126 } else {
127 Err(guard)
128 }
129 }
130
131 /// Take ownership of the Guard for `(buffer, mode)`,
132 /// bumping the epoch so any in-flight spawn that hasn't
133 /// inserted yet fails its [`Self::try_insert`] check.
134 /// Returns `None` if no Guard was stashed.
135 pub fn remove(&mut self, buffer: BufferId, mode: ModeId) -> Option<Box<dyn Any + Send>> {
136 // Bump first so a spawn task's later try_insert (after
137 // its on_activate.await resolves) sees the mismatch
138 // regardless of whether a Guard was already present.
139 self.bump_epoch(buffer, mode);
140 self.map.remove(&(buffer, mode))
141 }
142
143 /// Drop every Guard belonging to `buffer`. Call when a
144 /// buffer is deleted -- the dispatcher's normal
145 /// deactivation path may not run if the buffer vanishes
146 /// before the App can deactivate its modes. Bumps the
147 /// epoch for every `(buffer, *)` entry so any in-flight
148 /// spawn for the purged buffer fails its later
149 /// [`Self::try_insert`].
150 pub fn purge_buffer(&mut self, buffer: BufferId) {
151 self.map.retain(|(b, _), _| *b != buffer);
152 for ((b, _), epoch) in self.epochs.iter_mut() {
153 if *b == buffer {
154 *epoch = epoch.wrapping_add(1);
155 }
156 }
157 }
158
159 /// Number of stashed Guards.
160 pub fn len(&self) -> usize {
161 self.map.len()
162 }
163
164 /// True when no Guard is stashed.
165 pub fn is_empty(&self) -> bool {
166 self.map.is_empty()
167 }
168
169 /// True iff a Guard is stashed for `(buffer, mode)`.
170 pub fn contains(&self, buffer: BufferId, mode: ModeId) -> bool {
171 self.map.contains_key(&(buffer, mode))
172 }
173}
174
175/// Cheap-clone, thread-safe handle to a [`GuardStore`]. The App
176/// owns one; the dispatcher clones it into each spawned
177/// lifecycle task so the task can lock + insert the Guard on
178/// completion. Locks are held briefly (single map mutation per
179/// activation / deactivation); `std::sync::Mutex` is correct
180/// because no `.await` happens inside the lock.
181#[derive(Clone, Default)]
182pub struct GuardStoreHandle {
183 inner: Arc<Mutex<GuardStore>>,
184}
185
186impl std::fmt::Debug for GuardStoreHandle {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 let guard = self.inner.lock();
189 match guard {
190 Ok(g) => f
191 .debug_struct("GuardStoreHandle")
192 .field("count", &g.len())
193 .finish_non_exhaustive(),
194 Err(_) => f.debug_struct("GuardStoreHandle").finish_non_exhaustive(),
195 }
196 }
197}
198
199impl GuardStoreHandle {
200 /// Fresh empty handle.
201 pub fn new() -> Self {
202 Self::default()
203 }
204
205 /// Stash a Guard unconditionally (skipping the epoch
206 /// check). Used in tests + the rare reload path; the
207 /// production dispatcher routes through
208 /// [`Self::try_insert`].
209 pub fn insert(&self, buffer: BufferId, mode: ModeId, guard: Box<dyn Any + Send>) {
210 if let Ok(mut store) = self.inner.lock() {
211 store.insert(buffer, mode, guard);
212 }
213 }
214
215 /// Bump + return the new epoch for `(buffer, mode)`. The
216 /// dispatcher's sync prefix calls this when queueing each
217 /// cascade step; the spawn task captures it and passes
218 /// back to [`Self::try_insert`].
219 pub fn bump_epoch(&self, buffer: BufferId, mode: ModeId) -> u64 {
220 self.inner
221 .lock()
222 .map(|mut store| store.bump_epoch(buffer, mode))
223 .unwrap_or(0)
224 }
225
226 /// Insert iff `my_epoch` still matches the current
227 /// epoch. Returns `Err(guard)` on stale; the caller drops
228 /// the Guard outside the lock so the original type's
229 /// `Drop` fires.
230 pub fn try_insert(
231 &self,
232 buffer: BufferId,
233 mode: ModeId,
234 my_epoch: u64,
235 guard: Box<dyn Any + Send>,
236 ) -> Result<(), Box<dyn Any + Send>> {
237 match self.inner.lock() {
238 Ok(mut store) => store.try_insert(buffer, mode, my_epoch, guard),
239 // Poisoned mutex: treat as "stale" so caller drops.
240 Err(_) => Err(guard),
241 }
242 }
243
244 /// Take ownership of the Guard. Bumps the epoch
245 /// (invalidating any in-flight spawn) then removes; the
246 /// caller drops the returned `Box`, firing the Guard's
247 /// `Drop` impl *outside* the lock.
248 pub fn remove(&self, buffer: BufferId, mode: ModeId) -> Option<Box<dyn Any + Send>> {
249 self.inner.lock().ok()?.remove(buffer, mode)
250 }
251
252 /// Drop every Guard for `buffer`. Used when a buffer is
253 /// deleted.
254 pub fn purge_buffer(&self, buffer: BufferId) {
255 if let Ok(mut store) = self.inner.lock() {
256 store.purge_buffer(buffer);
257 }
258 }
259
260 /// True iff a Guard is stashed for `(buffer, mode)`.
261 pub fn contains(&self, buffer: BufferId, mode: ModeId) -> bool {
262 self.inner
263 .lock()
264 .map(|s| s.contains(buffer, mode))
265 .unwrap_or(false)
266 }
267
268 /// Number of stashed Guards.
269 pub fn len(&self) -> usize {
270 self.inner.lock().map(|s| s.len()).unwrap_or(0)
271 }
272
273 /// True when no Guard is stashed (or the lock is poisoned).
274 pub fn is_empty(&self) -> bool {
275 self.len() == 0
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 #![allow(clippy::unwrap_used)]
282 use super::*;
283 use std::sync::Arc;
284 use std::sync::atomic::{AtomicU32, Ordering};
285
286 /// Test Guard that increments a counter on drop, proving
287 /// `Box<dyn Any + Send>::drop` correctly invokes the
288 /// original type's `Drop` via the vtable.
289 struct DropCounter {
290 count: Arc<AtomicU32>,
291 }
292 impl Drop for DropCounter {
293 fn drop(&mut self) {
294 self.count.fetch_add(1, Ordering::SeqCst);
295 }
296 }
297
298 #[test]
299 fn insert_then_remove_drops_guard() {
300 let count = Arc::new(AtomicU32::new(0));
301 let mut store = GuardStore::new();
302 store.insert(
303 BufferId::new(1),
304 ModeId::new("x-mode"),
305 Box::new(DropCounter {
306 count: count.clone(),
307 }),
308 );
309 // Drop hasn't fired yet -- Guard is owned by the store.
310 assert_eq!(count.load(Ordering::SeqCst), 0);
311 let removed = store.remove(BufferId::new(1), ModeId::new("x-mode"));
312 // Box is now in `removed`; still hasn't dropped.
313 assert!(removed.is_some());
314 // Dropping the removed Box fires the original Guard's Drop.
315 drop(removed);
316 assert_eq!(count.load(Ordering::SeqCst), 1);
317 }
318
319 #[test]
320 fn remove_missing_returns_none() {
321 let mut store = GuardStore::new();
322 assert!(
323 store
324 .remove(BufferId::new(1), ModeId::new("x-mode"))
325 .is_none()
326 );
327 }
328
329 #[test]
330 fn purge_buffer_drops_every_mode_for_that_buffer() {
331 let c1 = Arc::new(AtomicU32::new(0));
332 let c2 = Arc::new(AtomicU32::new(0));
333 let other = Arc::new(AtomicU32::new(0));
334 let mut store = GuardStore::new();
335 store.insert(
336 BufferId::new(1),
337 ModeId::new("a-mode"),
338 Box::new(DropCounter { count: c1.clone() }),
339 );
340 store.insert(
341 BufferId::new(1),
342 ModeId::new("b-mode"),
343 Box::new(DropCounter { count: c2.clone() }),
344 );
345 store.insert(
346 BufferId::new(2),
347 ModeId::new("a-mode"),
348 Box::new(DropCounter {
349 count: other.clone(),
350 }),
351 );
352 store.purge_buffer(BufferId::new(1));
353 // Buffer 1's two Guards dropped; buffer 2 intact.
354 assert_eq!(c1.load(Ordering::SeqCst), 1);
355 assert_eq!(c2.load(Ordering::SeqCst), 1);
356 assert_eq!(other.load(Ordering::SeqCst), 0);
357 assert_eq!(store.len(), 1);
358 assert!(store.contains(BufferId::new(2), ModeId::new("a-mode")));
359 }
360
361 #[test]
362 fn insert_replaces_existing_and_drops_old() {
363 let old = Arc::new(AtomicU32::new(0));
364 let new = Arc::new(AtomicU32::new(0));
365 let mut store = GuardStore::new();
366 store.insert(
367 BufferId::new(1),
368 ModeId::new("x-mode"),
369 Box::new(DropCounter { count: old.clone() }),
370 );
371 store.insert(
372 BufferId::new(1),
373 ModeId::new("x-mode"),
374 Box::new(DropCounter { count: new.clone() }),
375 );
376 // Old Guard dropped at replacement time.
377 assert_eq!(old.load(Ordering::SeqCst), 1);
378 assert_eq!(new.load(Ordering::SeqCst), 0);
379 }
380
381 #[test]
382 fn unit_guard_works() {
383 // Marker modes use `Guard = ()`; storing/removing it
384 // must not panic.
385 let mut store = GuardStore::new();
386 store.insert(BufferId::new(1), ModeId::new("marker-mode"), Box::new(()));
387 let g = store.remove(BufferId::new(1), ModeId::new("marker-mode"));
388 assert!(g.is_some());
389 }
390}