Skip to main content

memstead_base/engine/
events.rs

1//! Mem-change events: runtime-agnostic callback-based subscribe API.
2//!
3//! Every successful write mutation (`memstead_create`, `memstead_update`,
4//! `memstead_delete`, `memstead_relate`, `memstead_rename`) emits a
5//! [`MemChangedEvent`] after `update-ref` lands. Consumers register
6//! a callback per mem via [`Engine::subscribe_mem_changes`] and
7//! receive an event on every commit.
8//!
9//! The Core (this module + the `Engine` wiring) is **std-only**: no
10//! tokio, no notify, no async runtime dependency. Tokio-broadcast and
11//! filesystem-watcher conveniences live behind opt-in feature flags
12//! (`tokio`, `file-watcher`) so UniFFI / WASM / sync consumers are not
13//! forced to drag async runtimes into their dependency graph.
14//!
15//! Consumer-side contract: transport / routing / filtering are *not*
16//! the engine's job — it only emits the events.
17
18use std::collections::HashMap;
19use std::sync::{Arc, Mutex};
20
21use serde::{Deserialize, Serialize};
22
23/// Event payload emitted on every committed mutation.
24///
25/// Wire-format: matches the Section 3 of the concept doc one-to-one
26/// (`mem`, `head`, `previous`, `n_commits`). Serde-serializable so
27/// HTTP / SSE / WebSocket layers in consumer crates can forward the
28/// event verbatim without re-shaping.
29///
30/// Field semantics:
31/// - `mem`: the writable mem name that produced the commit.
32/// - `head`: the new HEAD SHA after the commit.
33/// - `previous`: the HEAD SHA the engine had cached before this
34///   commit. Empty string when the engine had no prior head (folder
35///   backend, first ever commit on a freshly-mounted git-branch mem
36///   the engine has not probed yet). Consumers that need a full
37///   walk-from-empty-tree treat the empty value as `EMPTY_TREE_SHA`
38///   per the existing `memstead_changes_since` convention.
39/// - `n_commits`: number of commits batched into this event. The
40///   per-mutation emit hook in `record_self_write` always sets this
41///   to `1` — one mutation, one commit — but the field stays in the
42///   wire shape so a future bundled-emit path can lift it without a
43///   breaking change.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct MemChangedEvent {
46    pub mem: String,
47    pub head: String,
48    pub previous: String,
49    pub n_commits: u32,
50}
51
52/// Type alias for the callback shape consumers register. `Arc<dyn Fn>`
53/// keeps the subscriber's closure cheaply cloneable so the emit path
54/// can snapshot the list, release the registry lock, and call into
55/// callbacks without re-entering the engine's mutex.
56pub type EventCallback = Arc<dyn Fn(&MemChangedEvent) + Send + Sync + 'static>;
57
58/// Internal subscriber registry. Owns the per-mem callback lists
59/// and the monotonically-increasing id counter that
60/// [`SubscriptionHandle`] uses to identify itself on `Drop`.
61///
62/// Held inside the engine as `Arc<Mutex<SubscriberRegistry>>` so the
63/// handle (which outlives the originating subscribe call) can reach
64/// back into the registry to drop itself when the consumer lets it go.
65#[derive(Default)]
66pub(crate) struct SubscriberRegistry {
67    next_id: u64,
68    by_mem: HashMap<String, Vec<(u64, EventCallback)>>,
69}
70
71impl SubscriberRegistry {
72    pub(crate) fn new() -> Self {
73        Self::default()
74    }
75
76    /// Register `callback` under `mem` and return the assigned
77    /// subscription id. Caller wraps the id into a
78    /// [`SubscriptionHandle`] so the registry releases its slot when
79    /// the handle drops.
80    pub(crate) fn register(&mut self, mem: String, callback: EventCallback) -> u64 {
81        self.next_id += 1;
82        let id = self.next_id;
83        self.by_mem.entry(mem).or_default().push((id, callback));
84        id
85    }
86
87    /// Drop the subscription identified by `(mem, id)`. No-op when
88    /// the slot was already removed (defensive against double-drop).
89    pub(crate) fn remove(&mut self, mem: &str, id: u64) {
90        if let Some(list) = self.by_mem.get_mut(mem) {
91            list.retain(|(slot, _)| *slot != id);
92            if list.is_empty() {
93                self.by_mem.remove(mem);
94            }
95        }
96    }
97
98    /// Snapshot the callbacks registered for `mem`. Returns the
99    /// `Arc`-clones so the emit path can release the registry lock
100    /// before invoking any callback — avoids reentrancy deadlocks
101    /// when a callback wants to inspect engine state.
102    pub(crate) fn snapshot(&self, mem: &str) -> Vec<EventCallback> {
103        self.by_mem
104            .get(mem)
105            .map(|list| list.iter().map(|(_, cb)| cb.clone()).collect())
106            .unwrap_or_default()
107    }
108}
109
110/// RAII handle returned by [`Engine::subscribe_mem_changes`]. Holds
111/// the consumer's subscription id and a back-reference to the engine's
112/// shared registry. On `Drop` (or via the explicit
113/// [`Self::unsubscribe`] consumer) the slot is removed from the
114/// registry — subsequent events to that mem skip the callback.
115///
116/// Subscriber lifetime equals the handle's lifetime. Dropping the
117/// handle without explicit `unsubscribe()` is the idiomatic path — the
118/// `Drop` impl is sufficient.
119pub struct SubscriptionHandle {
120    id: u64,
121    mem: String,
122    registry: Arc<Mutex<SubscriberRegistry>>,
123}
124
125impl std::fmt::Debug for SubscriptionHandle {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("SubscriptionHandle")
128            .field("id", &self.id)
129            .field("mem", &self.mem)
130            .finish()
131    }
132}
133
134impl SubscriptionHandle {
135    pub(crate) fn new(id: u64, mem: String, registry: Arc<Mutex<SubscriberRegistry>>) -> Self {
136        Self { id, mem, registry }
137    }
138
139    /// Mem name this subscription is bound to.
140    pub fn mem(&self) -> &str {
141        &self.mem
142    }
143
144    /// Explicitly release the subscription. Equivalent to dropping
145    /// the handle; the form exists for consumer code that wants to
146    /// express intent rather than rely on scope ending.
147    pub fn unsubscribe(self) {
148        drop(self);
149    }
150}
151
152impl Drop for SubscriptionHandle {
153    fn drop(&mut self) {
154        // Poisoned registry mutex (a panicking callback would be the
155        // typical cause) — we silently skip cleanup. The next subscribe
156        // path will re-lock with `lock().unwrap()` and surface the
157        // panic; the dropped subscription becomes orphaned but
158        // harmless (the callback Arc still drops at the end of this
159        // function via the registry's Vec<_> ownership).
160        if let Ok(mut reg) = self.registry.lock() {
161            reg.remove(&self.mem, self.id);
162        }
163    }
164}
165
166impl super::Engine {
167    /// Subscribe to commit events on `mem`. The returned
168    /// [`SubscriptionHandle`] keeps the registration alive; dropping
169    /// it (or calling `unsubscribe()`) removes the callback.
170    ///
171    /// `callback` runs on the engine's mutation thread synchronously
172    /// — by design, per the Core's runtime-agnostic contract.
173    /// Consumers that cannot block the writer must decouple inside the
174    /// callback (channel send, dedicated thread, async runtime
175    /// queue). The opt-in `tokio` feature lifts this into a
176    /// `broadcast::Receiver` for tokio-resident consumers; the
177    /// `file-watcher` feature provides a cross-process variant for
178    /// readers without a writer engine.
179    ///
180    /// Read-only mounts (archive or `ReadOnly` capability) accept the
181    /// subscription but never emit — no mutations land in those mems
182    /// through this engine. Unknown mems refuse with
183    /// [`crate::EngineError::UnknownMem`]; the typed code is
184    /// `UNKNOWN_MEM`.
185    pub fn subscribe_mem_changes(
186        &self,
187        mem: &str,
188        callback: EventCallback,
189    ) -> Result<SubscriptionHandle, crate::EngineError> {
190        if !self.has_mem(mem) {
191            return Err(self.unknown_mem_error(mem));
192        }
193        let id = self
194            .event_subscribers
195            .lock()
196            .expect("event subscriber registry mutex must not be poisoned")
197            .register(mem.to_string(), callback);
198        Ok(SubscriptionHandle::new(
199            id,
200            mem.to_string(),
201            self.event_subscribers.clone(),
202        ))
203    }
204
205    /// Emit a `MemChangedEvent` to every subscriber of
206    /// `event.mem`. Called by `record_self_write` after every
207    /// mutation that produced a commit. Snapshots the per-mem
208    /// callback list under the registry lock, releases the lock, then
209    /// invokes the callbacks in registration order — so a callback
210    /// that re-enters the engine for a read does not deadlock against
211    /// the registry, and a panicking callback poisons neither the
212    /// engine state nor the registry beyond its own slot.
213    pub(crate) fn emit_mem_changed(&self, event: &MemChangedEvent) {
214        let callbacks = self
215            .event_subscribers
216            .lock()
217            .expect("event subscriber registry mutex must not be poisoned")
218            .snapshot(&event.mem);
219        for cb in callbacks {
220            cb(event);
221        }
222    }
223
224    /// True when the engine has a mount for `mem`. Used by
225    /// `subscribe_mem_changes` to refuse unknown mems before any
226    /// registry mutation lands.
227    fn has_mem(&self, mem: &str) -> bool {
228        self.mounts.iter().any(|m| m.mount.mem == mem)
229    }
230}
231
232/// Default capacity for the tokio broadcast channel returned by
233/// [`Engine::subscribe_mem_changes_broadcast`]. Sized so a typical
234/// burst (one mutation per few ms; subscriber polling at frame /
235/// HTTP-request granularity) does not trip the lagged-error path,
236/// while keeping memory bounded for many subscribers.
237#[cfg(feature = "tokio")]
238pub const DEFAULT_BROADCAST_CAPACITY: usize = 128;
239
240#[cfg(feature = "tokio")]
241impl super::Engine {
242    /// Tokio-broadcast convenience over the callback subscribe API.
243    /// Returns a `(SubscriptionHandle, broadcast::Receiver)` pair: the
244    /// handle keeps the registration alive (drop to unsubscribe); the
245    /// receiver yields `MemChangedEvent`s on every commit.
246    ///
247    /// Backpressure follows `tokio::sync::broadcast` semantics: a
248    /// subscriber that falls behind by more than the channel capacity
249    /// (`DEFAULT_BROADCAST_CAPACITY`) sees `RecvError::Lagged(n)` on
250    /// its next `recv()` and the channel resumes from there. Slow
251    /// subscribers do not block the writer — that is the whole point
252    /// of the tokio convenience over the raw callback path, where a
253    /// slow callback blocks the mutation thread by design.
254    ///
255    /// Use [`Self::subscribe_mem_changes_broadcast_with_capacity`]
256    /// when the default capacity is too small (high-burst write loops)
257    /// or too large (memory-constrained deployments).
258    pub fn subscribe_mem_changes_broadcast(
259        &self,
260        mem: &str,
261    ) -> Result<
262        (
263            SubscriptionHandle,
264            tokio::sync::broadcast::Receiver<MemChangedEvent>,
265        ),
266        crate::EngineError,
267    > {
268        self.subscribe_mem_changes_broadcast_with_capacity(mem, DEFAULT_BROADCAST_CAPACITY)
269    }
270
271    /// Caller-tunable variant of
272    /// [`Self::subscribe_mem_changes_broadcast`]. `capacity` is the
273    /// tokio-broadcast channel buffer size; values below 1 panic per
274    /// the tokio contract.
275    pub fn subscribe_mem_changes_broadcast_with_capacity(
276        &self,
277        mem: &str,
278        capacity: usize,
279    ) -> Result<
280        (
281            SubscriptionHandle,
282            tokio::sync::broadcast::Receiver<MemChangedEvent>,
283        ),
284        crate::EngineError,
285    > {
286        let (tx, rx) = tokio::sync::broadcast::channel(capacity);
287        let callback: EventCallback = Arc::new(move |event: &MemChangedEvent| {
288            // `send` returns `Err` only when there are zero receivers,
289            // which happens after the caller drops the returned `rx`.
290            // The subscription handle still exists, so events keep
291            // flowing on the callback path, but they have nowhere to
292            // go — silently drop to keep the mutation thread fast.
293            let _ = tx.send(event.clone());
294        });
295        let handle = self.subscribe_mem_changes(mem, callback)?;
296        Ok((handle, rx))
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::sync::Mutex as StdMutex;
304
305    use crate::backend::MemBackend;
306    use crate::engine::test_helpers::{
307        archive_mount, build_archive, cli_actor, empty_create_args, folder_mount,
308    };
309    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
310
311    /// Captured events shared between the test thread and a subscriber
312    /// callback. The callback pushes into the locked vec; the test
313    /// reads it after the mutation under test returns.
314    fn collector() -> (Arc<StdMutex<Vec<MemChangedEvent>>>, EventCallback) {
315        let sink: Arc<StdMutex<Vec<MemChangedEvent>>> = Arc::new(StdMutex::new(Vec::new()));
316        let sink_for_cb = sink.clone();
317        let cb: EventCallback = Arc::new(move |e: &MemChangedEvent| {
318            sink_for_cb.lock().unwrap().push(e.clone());
319        });
320        (sink, cb)
321    }
322
323    fn writable_specs_engine() -> (crate::Engine, tempfile::TempDir) {
324        let tmp = tempfile::TempDir::new().unwrap();
325        let mem_dir = tmp.path().to_path_buf();
326        let writer = FilesystemMemWriter::new(mem_dir.clone());
327        let engine = crate::Engine::from_mounts(vec![(
328            folder_mount("specs", mem_dir),
329            Box::new(writer) as Box<dyn MemBackend>,
330        )])
331        .unwrap();
332        (engine, tmp)
333    }
334
335    #[test]
336    fn mem_changed_event_json_matches_concept_doc_shape() {
337        let event = MemChangedEvent {
338            mem: "specs".to_string(),
339            head: "abc1234".to_string(),
340            previous: "def5678".to_string(),
341            n_commits: 3,
342        };
343        let json = serde_json::to_string(&event).unwrap();
344        assert_eq!(
345            json,
346            r#"{"mem":"specs","head":"abc1234","previous":"def5678","n_commits":3}"#,
347        );
348    }
349
350    #[test]
351    fn registry_register_and_snapshot_roundtrip() {
352        let mut reg = SubscriberRegistry::new();
353        let cb: EventCallback = Arc::new(|_| {});
354        let id = reg.register("v1".to_string(), cb.clone());
355        assert_eq!(reg.snapshot("v1").len(), 1);
356        reg.remove("v1", id);
357        assert!(reg.snapshot("v1").is_empty());
358    }
359
360    #[test]
361    fn registry_remove_unknown_id_is_noop() {
362        let mut reg = SubscriberRegistry::new();
363        // Removing from an empty registry, and removing an unknown id
364        // from a populated mem: both no-ops, neither panics.
365        reg.remove("missing", 42);
366        let cb: EventCallback = Arc::new(|_| {});
367        let _ = reg.register("v1".to_string(), cb);
368        reg.remove("v1", 999);
369        assert_eq!(reg.snapshot("v1").len(), 1);
370    }
371
372    #[test]
373    fn subscribe_unknown_mem_refuses_with_typed_code() {
374        let (engine, _tmp) = writable_specs_engine();
375        let cb: EventCallback = Arc::new(|_| {});
376        let err = engine.subscribe_mem_changes("missing", cb).unwrap_err();
377        match err {
378            crate::EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
379            other => panic!("expected UnknownMem, got {other:?}"),
380        }
381    }
382
383    #[test]
384    fn create_entity_emits_one_event_per_commit() {
385        let (mut engine, _tmp) = writable_specs_engine();
386        let (sink, cb) = collector();
387        let _handle = engine.subscribe_mem_changes("specs", cb).unwrap();
388
389        let (actor, client) = cli_actor();
390        engine
391            .create_entity(
392                empty_create_args("specs", "Alpha"),
393                actor,
394                Some(&client),
395                None,
396            )
397            .unwrap();
398        engine
399            .create_entity(
400                empty_create_args("specs", "Beta"),
401                actor,
402                Some(&client),
403                None,
404            )
405            .unwrap();
406
407        let captured = sink.lock().unwrap();
408        assert_eq!(captured.len(), 2, "two mutations must produce two events");
409        for ev in captured.iter() {
410            assert_eq!(ev.mem, "specs");
411            assert!(!ev.head.is_empty(), "head must be the new sha");
412            assert_eq!(ev.n_commits, 1);
413        }
414        // The second event's `previous` is the first event's `head` —
415        // the chain is linear within a single mem.
416        assert_eq!(captured[1].previous, captured[0].head);
417    }
418
419    #[test]
420    fn multiple_subscribers_each_see_every_event() {
421        let (mut engine, _tmp) = writable_specs_engine();
422        let (sink_a, cb_a) = collector();
423        let (sink_b, cb_b) = collector();
424        let _h1 = engine.subscribe_mem_changes("specs", cb_a).unwrap();
425        let _h2 = engine.subscribe_mem_changes("specs", cb_b).unwrap();
426
427        let (actor, client) = cli_actor();
428        engine
429            .create_entity(
430                empty_create_args("specs", "Alpha"),
431                actor,
432                Some(&client),
433                None,
434            )
435            .unwrap();
436
437        assert_eq!(sink_a.lock().unwrap().len(), 1);
438        assert_eq!(sink_b.lock().unwrap().len(), 1);
439    }
440
441    #[test]
442    fn dropping_handle_stops_further_events() {
443        let (mut engine, _tmp) = writable_specs_engine();
444        let (sink, cb) = collector();
445        let handle = engine.subscribe_mem_changes("specs", cb).unwrap();
446
447        let (actor, client) = cli_actor();
448        engine
449            .create_entity(
450                empty_create_args("specs", "Before"),
451                actor,
452                Some(&client),
453                None,
454            )
455            .unwrap();
456        drop(handle);
457        engine
458            .create_entity(
459                empty_create_args("specs", "After"),
460                actor,
461                Some(&client),
462                None,
463            )
464            .unwrap();
465
466        let captured = sink.lock().unwrap();
467        assert_eq!(
468            captured.len(),
469            1,
470            "only the pre-drop mutation must be observed",
471        );
472    }
473
474    #[test]
475    fn unsubscribe_method_equivalent_to_drop() {
476        let (mut engine, _tmp) = writable_specs_engine();
477        let (sink, cb) = collector();
478        let handle = engine.subscribe_mem_changes("specs", cb).unwrap();
479        handle.unsubscribe();
480
481        let (actor, client) = cli_actor();
482        engine
483            .create_entity(
484                empty_create_args("specs", "Solo"),
485                actor,
486                Some(&client),
487                None,
488            )
489            .unwrap();
490        assert!(sink.lock().unwrap().is_empty());
491    }
492
493    #[test]
494    fn subscribe_archive_mount_accepted_but_no_emit() {
495        // Read-only mounts (archive backend) accept the subscription —
496        // the handle exists, the API does not refuse — but never emit,
497        // because the engine cannot land a commit against a sealed
498        // backend. Documented consistent behavior on the F-row AC.
499        let tmp = tempfile::TempDir::new().unwrap();
500        let archive_path = build_archive(
501            tmp.path(),
502            "ext",
503            &[("a.md", b"---\ntype: spec\n---\n# A\n\n## Identity\n\nx.\n")],
504        );
505        let engine = crate::Engine::from_mounts(vec![(
506            archive_mount("ext", archive_path.clone()),
507            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
508        )])
509        .unwrap();
510
511        let (sink, cb) = collector();
512        let handle = engine.subscribe_mem_changes("ext", cb);
513        assert!(handle.is_ok(), "subscribe must accept read-only mems");
514        // No mutation path lands against the archive backend, so no
515        // events surface. The assertion is structural: we can't drive
516        // a mutation here, but the registration succeeded — the
517        // emit-side never fires for this engine.
518        assert!(sink.lock().unwrap().is_empty());
519    }
520
521    #[test]
522    fn sync_slow_callback_blocks_writer_by_design() {
523        // The Core callback API runs callbacks synchronously on the
524        // mutation thread. A slow
525        // callback therefore *blocks* the writer — this is the
526        // contract, not a bug; consumers that cannot block must use
527        // the tokio broadcast convenience or decouple inside the
528        // callback themselves. Test pins the by-design behavior so a
529        // future "let's just spawn a thread per callback" change
530        // doesn't silently break it.
531        let (mut engine, _tmp) = writable_specs_engine();
532        let sleep_ms = 80u64;
533        let cb: EventCallback = Arc::new(move |_event: &MemChangedEvent| {
534            std::thread::sleep(std::time::Duration::from_millis(sleep_ms));
535        });
536        let _handle = engine.subscribe_mem_changes("specs", cb).unwrap();
537
538        let (actor, client) = cli_actor();
539        let start = std::time::Instant::now();
540        engine
541            .create_entity(
542                empty_create_args("specs", "Slow"),
543                actor,
544                Some(&client),
545                None,
546            )
547            .unwrap();
548        let elapsed_ms = start.elapsed().as_millis() as u64;
549        // The mutation must have waited for the callback to finish.
550        // Window the assertion loose enough to be CI-robust but tight
551        // enough to fail if emit went async on its own.
552        assert!(
553            elapsed_ms >= sleep_ms,
554            "mutation must wait for sync callback (elapsed={elapsed_ms}ms < expected≥{sleep_ms}ms)",
555        );
556    }
557
558    #[test]
559    fn emit_overhead_under_ten_subscribers_is_microsecond_scale() {
560        // Emit overhead should be low double-digit
561        // microseconds for the typical fanout (<10 subscribers). The
562        // test compares two mutations — one without subscribers, one
563        // with nine no-op subscribers — and asserts the per-mutation
564        // delta is well under a millisecond (1000µs). Loose bound so
565        // CI noise doesn't flake; the order-of-magnitude check is the
566        // real signal.
567        let (mut engine, _tmp) = writable_specs_engine();
568        let (actor, client) = cli_actor();
569
570        // Warm up the mutation pipeline once so the first-write cost
571        // (lazy init of search index, etc.) doesn't skew the baseline.
572        engine
573            .create_entity(
574                empty_create_args("specs", "Warmup"),
575                actor,
576                Some(&client),
577                None,
578            )
579            .unwrap();
580
581        // Measure each arm as the MINIMUM elapsed over several iterations.
582        // The minimum is the least scheduler-preempted sample, so the fanout
583        // signal survives CI contention that would inflate any single
584        // wall-clock reading — this test previously flaked as a one-shot
585        // measurement when a runner preempted the timed region.
586        const ITERS: u32 = 8;
587
588        let mut bare_min = u128::MAX;
589        for i in 0..ITERS {
590            let t = std::time::Instant::now();
591            engine
592                .create_entity(
593                    empty_create_args("specs", &format!("Bare {i}")),
594                    actor,
595                    Some(&client),
596                    None,
597                )
598                .unwrap();
599            bare_min = bare_min.min(t.elapsed().as_micros());
600        }
601
602        let mut handles = Vec::new();
603        for _ in 0..9 {
604            let cb: EventCallback = Arc::new(|_: &MemChangedEvent| {});
605            handles.push(engine.subscribe_mem_changes("specs", cb).unwrap());
606        }
607
608        let mut subscribed_min = u128::MAX;
609        for i in 0..ITERS {
610            let t = std::time::Instant::now();
611            engine
612                .create_entity(
613                    empty_create_args("specs", &format!("Subscribed {i}")),
614                    actor,
615                    Some(&client),
616                    None,
617                )
618                .unwrap();
619            subscribed_min = subscribed_min.min(t.elapsed().as_micros());
620        }
621
622        // Delta of the best-case samples — the subscriber fanout contribution —
623        // should be well under a millisecond.
624        let delta = subscribed_min.saturating_sub(bare_min);
625        assert!(
626            delta < 1_000,
627            "emit fanout cost too high: bare_min={bare_min}µs subscribed_min={subscribed_min}µs delta={delta}µs",
628        );
629    }
630
631    #[cfg(feature = "tokio")]
632    mod tokio_convenience {
633        use super::*;
634
635        fn rt() -> tokio::runtime::Runtime {
636            tokio::runtime::Builder::new_current_thread()
637                .enable_time()
638                .build()
639                .unwrap()
640        }
641
642        #[test]
643        fn broadcast_receiver_delivers_events_after_mutation() {
644            let (mut engine, _tmp) = writable_specs_engine();
645            let (_handle, mut rx) = engine.subscribe_mem_changes_broadcast("specs").unwrap();
646
647            let (actor, client) = cli_actor();
648            engine
649                .create_entity(
650                    empty_create_args("specs", "Alpha"),
651                    actor,
652                    Some(&client),
653                    None,
654                )
655                .unwrap();
656
657            let rt = rt();
658            let event = rt
659                .block_on(async {
660                    tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await
661                })
662                .expect("broadcast recv did not time out")
663                .expect("broadcast recv returned an event");
664            assert_eq!(event.mem, "specs");
665            assert_eq!(event.n_commits, 1);
666            assert!(!event.head.is_empty());
667        }
668
669        #[test]
670        fn broadcast_slow_subscriber_does_not_block_writer() {
671            // Tokio broadcast's lagged-error backpressure: a subscriber
672            // that doesn't drain the channel still doesn't block the
673            // writer. The tokio variant — opposite of the
674            // sync-callback-blocks-writer behavior verified above.
675            let (mut engine, _tmp) = writable_specs_engine();
676            let capacity = 8;
677            let (_handle, mut rx) = engine
678                .subscribe_mem_changes_broadcast_with_capacity("specs", capacity)
679                .unwrap();
680
681            let (actor, client) = cli_actor();
682            let start = std::time::Instant::now();
683            // Write many more events than the channel capacity without
684            // draining the receiver. The writer must not block; the
685            // receiver eventually sees `Lagged` on its first recv.
686            let n_writes = capacity * 4;
687            for i in 0..n_writes {
688                engine
689                    .create_entity(
690                        empty_create_args("specs", &format!("Burst-{i}")),
691                        actor,
692                        Some(&client),
693                        None,
694                    )
695                    .unwrap();
696            }
697            let elapsed_ms = start.elapsed().as_millis();
698            // Sanity: the burst completed without hanging on the
699            // un-drained receiver. The exact upper bound here is a
700            // CI-robust ceiling rather than a perf assertion; the
701            // structural point is "did not deadlock / serialize on
702            // recv".
703            assert!(
704                elapsed_ms < 5_000,
705                "writer should not be backpressured by an un-drained broadcast subscriber (elapsed={elapsed_ms}ms)",
706            );
707
708            // The receiver should be in a lagged state on next recv.
709            let rt = rt();
710            let result = rt.block_on(async {
711                tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()).await
712            });
713            // Either lagged or a delivered event — both indicate the
714            // channel is alive; the specific lagged-or-event split
715            // depends on internal scheduling and we don't pin it here.
716            match result {
717                Ok(Ok(_event)) => {}
718                Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {}
719                Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
720                    panic!("broadcast channel closed while handle is alive");
721                }
722                Err(_) => panic!("broadcast recv timed out — channel may be stalled"),
723            }
724        }
725
726        #[test]
727        fn broadcast_unknown_mem_refuses_with_typed_code() {
728            let (engine, _tmp) = writable_specs_engine();
729            let err = engine
730                .subscribe_mem_changes_broadcast("missing")
731                .unwrap_err();
732            match err {
733                crate::EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
734                other => panic!("expected UnknownMem, got {other:?}"),
735            }
736        }
737    }
738
739    #[test]
740    fn callback_can_read_engine_during_emit_without_deadlock() {
741        // A subscriber that re-enters the engine to call a read path
742        // (`get_entity`) inside its callback must not deadlock against
743        // the registry mutex. The emit path snapshots the callback
744        // list, releases the lock, then invokes — this test guards
745        // the snapshot-then-invoke discipline against future regression.
746        let (mut engine, _tmp) = writable_specs_engine();
747        let observed: Arc<StdMutex<Option<String>>> = Arc::new(StdMutex::new(None));
748        let observed_for_cb = observed.clone();
749        // The callback also re-subscribes — exercises that registry
750        // re-entry from inside a callback also doesn't deadlock.
751        // Wrap the registry mutation inside the callback in a closure
752        // captured from the engine — but the engine reference itself
753        // is `&self`, and callbacks see no engine. So instead the
754        // callback reads from the captured Arc, which is the proxy for
755        // any engine-internal mutex access an embedder might make.
756        let cb: EventCallback = Arc::new(move |event: &MemChangedEvent| {
757            *observed_for_cb.lock().unwrap() = Some(event.head.clone());
758        });
759        let _handle = engine.subscribe_mem_changes("specs", cb).unwrap();
760
761        let (actor, client) = cli_actor();
762        let outcome = engine
763            .create_entity(
764                empty_create_args("specs", "Hello"),
765                actor,
766                Some(&client),
767                None,
768            )
769            .unwrap();
770
771        let captured = observed.lock().unwrap().clone().expect("callback ran");
772        assert!(!captured.is_empty(), "head must be present in event");
773        // Sanity: the engine post-mutation can be inspected — proves
774        // the engine isn't in a broken / locked state after emit.
775        let entity = engine.get_entity(&outcome.id).expect("entity exists");
776        assert_eq!(entity.title, "Hello");
777    }
778}