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(crate::EngineError::UnknownMem(mem.to_string()));
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}