Skip to main content

spvirit_server/
events.rs

1//! Server-wide lifecycle hooks and named events.
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, RwLock};
8
9use tokio::sync::mpsc;
10use tracing::warn;
11
12use crate::simple_store::SimplePvStore;
13
14/// Maximum number of queued handler invocations before new ones are dropped.
15pub const DISPATCH_QUEUE_CAPACITY: usize = 1024;
16
17/// An inline, awaited consumer of named events.
18///
19/// Implementors are awaited by [`Events::post`], in registration order,
20/// before any deferred handler is queued — when `post` returns, every sink
21/// has finished its work. That is what makes the "records have processed
22/// when `post_event` returns" guarantee true.
23/// Sub-project B implements this on its `Scanner` to drive `EVNT` scan lists.
24///
25/// The method returns a boxed future rather than being a plain `fn`, for the
26/// same reason [`EventHandler`] does: every store mutation a realistic sink
27/// needs (`SimplePvStore::set_value` and friends) is `async`, and a sync
28/// trait would force implementors to block on a future from inside `post` —
29/// which is reachable from the dispatcher task on a `current_thread`
30/// runtime, where `Handle::block_on` panics, `block_in_place` panics, and
31/// `futures::executor::block_on` deadlocks against the store's tokio
32/// `RwLock`. There is no correct way to honour a sync signature.
33///
34/// The returned future borrows `&self`, not `event` — copy the name in if
35/// the future needs it (`let event = event.to_string();`).
36///
37/// A panicking sink is caught, counted in [`Events::failed_count`], and the
38/// remaining sinks still run: one bad sink can neither truncate the fan-out
39/// nor stop handlers from being queued.
40pub trait EventSink: Send + Sync {
41    /// Handle `event`. Awaited inline by `post`; keep it short.
42    fn on_event(&self, event: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
43}
44
45/// A deferred event handler.
46///
47/// Receives the store and the event name. Returns a boxed future because
48/// `SimplePvStore::set_value` is async — a plain `Fn` could not touch the
49/// store at all.
50pub type EventHandler = Arc<
51    dyn Fn(Arc<SimplePvStore>, String) -> Pin<Box<dyn Future<Output = ()> + Send>>
52        + Send
53        + Sync,
54>;
55
56/// A startup hook. Runs once, to completion, before the server serves.
57pub type StartHook =
58    Arc<dyn Fn(Arc<SimplePvStore>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
59
60/// One queued unit of work: a handler plus the event that triggered it.
61struct Dispatch {
62    handler: EventHandler,
63    event: String,
64}
65
66/// Server-wide event registry.
67///
68/// Owns the inline sinks and the deferred handlers. Sinks are awaited by
69/// `post` before it returns; handlers are queued and run one at a time on a
70/// single dispatcher task.
71pub struct Events {
72    sinks: RwLock<Vec<Arc<dyn EventSink>>>,
73    handlers: RwLock<HashMap<String, Vec<EventHandler>>>,
74    tx: mpsc::Sender<Dispatch>,
75    rx: RwLock<Option<mpsc::Receiver<Dispatch>>>,
76    dropped: AtomicU64,
77    failed: Arc<AtomicU64>,
78    /// Set by `start_dispatcher`. `drain()` and `post()` read it so that
79    /// "you forgot to start the server" is diagnosed immediately and by
80    /// name, instead of as a 10 s spin blamed on a stuck dispatcher.
81    dispatcher_started: std::sync::atomic::AtomicBool,
82    /// Incremented on every successful enqueue, decremented after the handler
83    /// finishes. `drain()` waits for this to reach zero.
84    inflight: Arc<AtomicU64>,
85}
86
87impl Events {
88    pub fn new() -> Self {
89        let (tx, rx) = mpsc::channel(DISPATCH_QUEUE_CAPACITY);
90        Self {
91            sinks: RwLock::new(Vec::new()),
92            handlers: RwLock::new(HashMap::new()),
93            tx,
94            rx: RwLock::new(Some(rx)),
95            dropped: AtomicU64::new(0),
96            failed: Arc::new(AtomicU64::new(0)),
97            dispatcher_started: std::sync::atomic::AtomicBool::new(false),
98            inflight: Arc::new(AtomicU64::new(0)),
99        }
100    }
101
102    /// Register an inline sink. Sinks are awaited in registration order.
103    ///
104    /// May be called at any time, including from inside another sink's
105    /// call-out; the new sink takes effect from the next `post`.
106    pub fn add_sink(&self, sink: Arc<dyn EventSink>) {
107        self.sinks.write().unwrap().push(sink);
108    }
109
110    /// Register a deferred handler for `event`.
111    ///
112    /// Handlers for one event run in registration order.
113    pub fn add_handler(&self, event: impl Into<String>, handler: EventHandler) {
114        self.handlers
115            .write()
116            .unwrap()
117            .entry(event.into())
118            .or_default()
119            .push(handler);
120    }
121
122    /// Number of handler invocations dropped because the queue was full.
123    pub fn dropped_count(&self) -> u64 {
124        self.dropped.load(Ordering::Relaxed)
125    }
126
127    /// Number of sink or handler invocations that panicked.
128    pub fn failed_count(&self) -> u64 {
129        self.failed.load(Ordering::Relaxed)
130    }
131
132    /// Start the single dispatcher task. Call once, at server start.
133    pub fn start_dispatcher(&self, store: Arc<SimplePvStore>) {
134        // Idempotent: `ServeBuilder::start` starts the dispatcher before it
135        // returns (so a handle-API caller can post immediately), and the
136        // spawned `serve_after_start_hooks` then calls this again.
137        let Some(mut rx) = self.rx.write().unwrap().take() else {
138            tracing::debug!("Events::start_dispatcher called again; already running");
139            return;
140        };
141        let inflight = self.inflight.clone();
142        let failed = self.failed.clone();
143        self.dispatcher_started.store(true, Ordering::SeqCst);
144        tokio::spawn(async move {
145            while let Some(Dispatch { handler, event }) = rx.recv().await {
146                let fut = handler(store.clone(), event.clone());
147                // AssertUnwindSafe: on panic we drop the handler's state and
148                // continue; the store's own invariants are upheld by its locks.
149                let result =
150                    futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(fut)).await;
151                if result.is_err() {
152                    warn!("event handler for '{}' panicked", event);
153                    failed.fetch_add(1, Ordering::Relaxed);
154                }
155                inflight.fetch_sub(1, Ordering::SeqCst);
156            }
157        });
158    }
159
160    /// Post `event`: await every sink, then queue handlers and return.
161    ///
162    /// Sinks run in registration order and are each wrapped in
163    /// `catch_unwind`, exactly as handlers are on the dispatcher: a
164    /// panicking sink is logged, counted in [`Self::failed_count`], and the
165    /// fan-out continues. Without that, one bad sink would unwind out of
166    /// `post` and the remaining sinks *and every handler* would silently
167    /// never see the event.
168    ///
169    /// An unknown event name is a no-op — events are a dynamic namespace.
170    pub async fn post(&self, event: &str) {
171        // Clone the list rather than holding the read guard across the
172        // call-outs: a sink is allowed to post, and to register further
173        // sinks, from inside its own call-out.
174        let sinks = self.sinks.read().unwrap().clone();
175        for sink in &sinks {
176            // AssertUnwindSafe: on panic we drop the sink's in-progress
177            // state and continue; the store's invariants are upheld by its
178            // own locks. The call itself is inside the async block so a
179            // panic while *building* the future is caught too.
180            let fut = async { sink.on_event(event).await };
181            let result = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(fut)).await;
182            if result.is_err() {
183                warn!("event sink for '{}' panicked; continuing fan-out", event);
184                self.failed.fetch_add(1, Ordering::Relaxed);
185            }
186        }
187
188        let handlers = {
189            let map = self.handlers.read().unwrap();
190            map.get(event).cloned().unwrap_or_default()
191        };
192        // Count the whole batch as in-flight before enqueueing any of it, so
193        // a concurrent drain() can never observe inflight == 0 partway
194        // through this loop (e.g. because the dispatcher already finished
195        // handler 1 while handler 2 has not been enqueued yet).
196        if !handlers.is_empty() && !self.dispatcher_started.load(Ordering::SeqCst) {
197            warn!(
198                "posted '{}' with {} handler(s) registered but the event dispatcher \
199                 has not started — nothing will run them until the server starts \
200                 (run()/start()/start_background())",
201                event,
202                handlers.len()
203            );
204        }
205        self.inflight
206            .fetch_add(handlers.len() as u64, Ordering::SeqCst);
207        for handler in handlers {
208            let queued = self.tx.try_send(Dispatch {
209                handler,
210                event: event.to_string(),
211            });
212            if queued.is_err() {
213                self.inflight.fetch_sub(1, Ordering::SeqCst);
214                let n = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
215                if n.is_power_of_two() {
216                    warn!(
217                        "event dispatch queue full; dropped handler for '{}' ({} dropped so far)",
218                        event, n
219                    );
220                }
221            }
222        }
223    }
224
225    /// Wait until every queued handler has finished. Test helper.
226    ///
227    /// Bounded rather than an unconditional spin: this doubles as a deadlock
228    /// detector. If a future change ever breaks the invariant that the
229    /// dispatcher always decrements `inflight` (e.g. removing the
230    /// `catch_unwind` around handler futures), a hung dispatcher must show up
231    /// as a clear, named test failure — not an indefinitely hanging CI job.
232    pub async fn drain(&self) {
233        const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
234        // `build() -> post_event() -> drain_events()` is the natural test
235        // shape, and forgetting to start the server is the natural mistake.
236        // Diagnose it here in microseconds instead of spinning a core for
237        // ten seconds and then blaming the dispatcher for being stuck.
238        if !self.dispatcher_started.load(Ordering::SeqCst)
239            && self.inflight.load(Ordering::SeqCst) > 0
240        {
241            panic!(
242                "Events::drain() called with {} handler invocation(s) queued but the \
243                 dispatcher was never started — nothing will ever run them. Start the \
244                 server (run() / start() / start_background(), or \
245                 Events::start_dispatcher) before posting events you intend to drain.",
246                self.inflight.load(Ordering::SeqCst)
247            );
248        }
249        let deadline = tokio::time::Instant::now() + DRAIN_TIMEOUT;
250        while self.inflight.load(Ordering::SeqCst) > 0 {
251            if tokio::time::Instant::now() >= deadline {
252                panic!(
253                    "Events::drain() timed out after {DRAIN_TIMEOUT:?} with {} handler(s) still in flight — \
254                     the dispatcher likely stopped consuming (e.g. a handler future \
255                     that never returns, or a panic no longer being caught)",
256                    self.inflight.load(Ordering::SeqCst)
257                );
258            }
259            tokio::task::yield_now().await;
260        }
261    }
262}
263
264impl Default for Events {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use std::sync::Mutex;
274    use std::sync::atomic::{AtomicUsize, Ordering};
275
276    /// Build a store with one f64 record, for handlers to write into.
277    fn test_store() -> Arc<crate::simple_store::SimplePvStore> {
278        use crate::pva_server::PvaServer;
279        let server = PvaServer::builder().ai("T:X", 0.0).build();
280        server.store().clone()
281    }
282
283    #[tokio::test]
284    async fn handlers_run_on_the_dispatcher_not_inline() {
285        let store = test_store();
286        let events = Events::new();
287        let ran = Arc::new(AtomicUsize::new(0));
288
289        let r = ran.clone();
290        events.add_handler(
291            "GO",
292            Arc::new(move |_store, _event| {
293                let r = r.clone();
294                Box::pin(async move {
295                    r.fetch_add(1, Ordering::SeqCst);
296                })
297            }),
298        );
299        events.start_dispatcher(store);
300
301        events.post("GO").await;
302        // Deferred: must not have run yet at the moment post() returned.
303        assert_eq!(ran.load(Ordering::SeqCst), 0, "handler ran inline");
304
305        events.drain().await;
306        assert_eq!(ran.load(Ordering::SeqCst), 1);
307    }
308
309    #[tokio::test]
310    async fn handlers_are_serialized_in_registration_order() {
311        let store = test_store();
312        let events = Events::new();
313        let log = Arc::new(Mutex::new(Vec::new()));
314
315        for label in ["first", "second", "third"] {
316            let log = log.clone();
317            events.add_handler(
318                "GO",
319                Arc::new(move |_store, _event| {
320                    let log = log.clone();
321                    let label = label.to_string();
322                    Box::pin(async move {
323                        log.lock().unwrap().push(format!("{label}:enter"));
324                        tokio::task::yield_now().await;
325                        log.lock().unwrap().push(format!("{label}:exit"));
326                    })
327                }),
328            );
329        }
330        events.start_dispatcher(store);
331
332        events.post("GO").await;
333        events.drain().await;
334
335        // Serialized: every enter is immediately followed by its own exit.
336        assert_eq!(
337            log.lock().unwrap().as_slice(),
338            &[
339                "first:enter".to_string(),
340                "first:exit".to_string(),
341                "second:enter".to_string(),
342                "second:exit".to_string(),
343                "third:enter".to_string(),
344                "third:exit".to_string(),
345            ]
346        );
347    }
348
349    #[tokio::test]
350    async fn only_handlers_for_the_posted_event_run() {
351        let store = test_store();
352        let events = Events::new();
353        let a = Arc::new(AtomicUsize::new(0));
354        let b = Arc::new(AtomicUsize::new(0));
355
356        let ac = a.clone();
357        events.add_handler("A", Arc::new(move |_s, _e| {
358            let ac = ac.clone();
359            Box::pin(async move { ac.fetch_add(1, Ordering::SeqCst); })
360        }));
361        let bc = b.clone();
362        events.add_handler("B", Arc::new(move |_s, _e| {
363            let bc = bc.clone();
364            Box::pin(async move { bc.fetch_add(1, Ordering::SeqCst); })
365        }));
366        events.start_dispatcher(store);
367
368        events.post("A").await;
369        events.drain().await;
370
371        assert_eq!(a.load(Ordering::SeqCst), 1);
372        assert_eq!(b.load(Ordering::SeqCst), 0);
373    }
374
375    #[tokio::test]
376    async fn handler_receives_the_event_name() {
377        let store = test_store();
378        let events = Events::new();
379        let seen = Arc::new(Mutex::new(Vec::new()));
380
381        let s = seen.clone();
382        events.add_handler("SHUTTER", Arc::new(move |_store, event| {
383            let s = s.clone();
384            Box::pin(async move { s.lock().unwrap().push(event); })
385        }));
386        events.start_dispatcher(store);
387
388        events.post("SHUTTER").await;
389        events.drain().await;
390
391        assert_eq!(seen.lock().unwrap().as_slice(), &["SHUTTER".to_string()]);
392    }
393
394    #[tokio::test]
395    async fn handler_can_write_the_store() {
396        let store = test_store();
397        let events = Events::new();
398
399        events.add_handler("BUMP", Arc::new(|store, _event| {
400            Box::pin(async move {
401                store
402                    .set_value("T:X", spvirit_types::ScalarValue::F64(42.0))
403                    .await;
404            })
405        }));
406        events.start_dispatcher(store.clone());
407
408        events.post("BUMP").await;
409        events.drain().await;
410
411        assert_eq!(
412            store.get_value("T:X").await,
413            Some(spvirit_types::ScalarValue::F64(42.0))
414        );
415    }
416
417    #[tokio::test]
418    async fn full_queue_drops_and_counts() {
419        let store = test_store();
420        let events = Events::new();
421        // A handler that blocks until released, so the queue backs up.
422        let gate = Arc::new(tokio::sync::Notify::new());
423        let g = gate.clone();
424        events.add_handler("FLOOD", Arc::new(move |_s, _e| {
425            let g = g.clone();
426            Box::pin(async move { g.notified().await; })
427        }));
428        events.start_dispatcher(store);
429
430        // #[tokio::test] defaults to current_thread, and with no sinks
431        // registered `post()` completes without ever returning `Pending`, so
432        // awaiting it never yields to the executor: the spawned dispatcher is
433        // never polled and never dequeues anything before we assert below —
434        // capacity is filled from the sender side alone, deterministically.
435        // Post well past capacity.
436        for _ in 0..(DISPATCH_QUEUE_CAPACITY + 50) {
437            events.post("FLOOD").await;
438        }
439
440        assert!(
441            events.dropped_count() > 0,
442            "expected drops once the queue filled, got {}",
443            events.dropped_count()
444        );
445
446        // Release everything so the test does not leak a blocked task.
447        gate.notify_waiters();
448    }
449
450    #[tokio::test]
451    async fn dispatcher_survives_a_panicking_handler() {
452        let store = test_store();
453        let events = Events::new();
454        let after = Arc::new(AtomicUsize::new(0));
455
456        events.add_handler("BOOM", Arc::new(|_s, _e| {
457            Box::pin(async { panic!("handler blew up"); })
458        }));
459        let a = after.clone();
460        events.add_handler("BOOM", Arc::new(move |_s, _e| {
461            let a = a.clone();
462            Box::pin(async move { a.fetch_add(1, Ordering::SeqCst); })
463        }));
464        events.start_dispatcher(store);
465
466        events.post("BOOM").await;
467        events.drain().await;
468
469        assert_eq!(
470            after.load(Ordering::SeqCst),
471            1,
472            "handler after the panicking one must still run"
473        );
474        assert_eq!(events.failed_count(), 1);
475    }
476
477    struct RecordingSink {
478        seen: Mutex<Vec<String>>,
479    }
480
481    impl EventSink for RecordingSink {
482        fn on_event(&self, event: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
483            let event = event.to_string();
484            Box::pin(async move {
485                self.seen.lock().unwrap().push(event);
486            })
487        }
488    }
489
490    #[tokio::test]
491    async fn post_calls_sinks_in_registration_order() {
492        let a = Arc::new(RecordingSink { seen: Mutex::new(Vec::new()) });
493        let b = Arc::new(RecordingSink { seen: Mutex::new(Vec::new()) });
494        let events = Events::new();
495        events.add_sink(a.clone());
496        events.add_sink(b.clone());
497
498        events.post("SHUTTER").await;
499
500        assert_eq!(a.seen.lock().unwrap().as_slice(), &["SHUTTER".to_string()]);
501        assert_eq!(b.seen.lock().unwrap().as_slice(), &["SHUTTER".to_string()]);
502    }
503
504    #[tokio::test]
505    async fn post_with_no_sinks_is_a_noop() {
506        let events = Events::new();
507        events.post("NOBODY:LISTENING").await;
508    }
509
510    #[tokio::test]
511    #[should_panic(expected = "the dispatcher was never started")]
512    async fn drain_without_a_dispatcher_fails_immediately_and_says_why() {
513        // `build() -> post_event() -> drain_events()` with no start in
514        // between: the old code spun the full 10 s DRAIN_TIMEOUT and then
515        // blamed the dispatcher for being stuck.
516        let events = Events::new();
517        events.add_handler(
518            "GO",
519            Arc::new(|_s, _e| Box::pin(async {})),
520        );
521        events.post("GO").await;
522        let t0 = std::time::Instant::now();
523        let hit = std::panic::AssertUnwindSafe(events.drain());
524        let result = futures::FutureExt::catch_unwind(hit).await;
525        assert!(
526            t0.elapsed() < std::time::Duration::from_secs(1),
527            "drain() must fail fast when the dispatcher never started, took {:?}",
528            t0.elapsed()
529        );
530        std::panic::resume_unwind(result.expect_err("drain() must panic"));
531    }
532
533    #[tokio::test]
534    async fn drain_with_nothing_queued_is_fine_without_a_dispatcher() {
535        // No handler invocations in flight means nothing to wait for; the
536        // dispatcher check must not turn that into a failure.
537        let events = Events::new();
538        events.post("NOBODY").await;
539        events.drain().await;
540    }
541
542    #[tokio::test]
543    async fn a_panicking_sink_does_not_truncate_the_fan_out() {
544        struct BoomSink;
545        impl EventSink for BoomSink {
546            fn on_event(&self, _event: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
547                Box::pin(async { panic!("sink blew up") })
548            }
549        }
550
551        let store = test_store();
552        let events = Events::new();
553        let before = Arc::new(RecordingSink { seen: Mutex::new(Vec::new()) });
554        let after = Arc::new(RecordingSink { seen: Mutex::new(Vec::new()) });
555        events.add_sink(before.clone());
556        events.add_sink(Arc::new(BoomSink));
557        events.add_sink(after.clone());
558
559        let handler_ran = Arc::new(AtomicUsize::new(0));
560        let h = handler_ran.clone();
561        events.add_handler(
562            "BOOM",
563            Arc::new(move |_s, _e| {
564                let h = h.clone();
565                Box::pin(async move {
566                    h.fetch_add(1, Ordering::SeqCst);
567                })
568            }),
569        );
570        events.start_dispatcher(store);
571
572        // Must not unwind out of post().
573        events.post("BOOM").await;
574        events.drain().await;
575
576        assert_eq!(before.seen.lock().unwrap().as_slice(), &["BOOM".to_string()]);
577        assert_eq!(
578            after.seen.lock().unwrap().as_slice(),
579            &["BOOM".to_string()],
580            "a sink after the panicking one must still see the event"
581        );
582        assert_eq!(
583            handler_ran.load(Ordering::SeqCst),
584            1,
585            "a panicking sink must not stop handlers from being queued"
586        );
587        assert_eq!(events.failed_count(), 1);
588    }
589
590    #[tokio::test]
591    async fn a_handler_may_post_another_event() {
592        let store = test_store();
593        let events = Arc::new(Events::new());
594        let log = Arc::new(Mutex::new(Vec::new()));
595
596        let l = log.clone();
597        let ev = events.clone();
598        events.add_handler("FIRST", Arc::new(move |_s, _e| {
599            let l = l.clone();
600            let ev = ev.clone();
601            Box::pin(async move {
602                l.lock().unwrap().push("first:enter".to_string());
603                ev.post("SECOND").await;
604                l.lock().unwrap().push("first:exit".to_string());
605            })
606        }));
607
608        let l = log.clone();
609        events.add_handler("SECOND", Arc::new(move |_s, _e| {
610            let l = l.clone();
611            Box::pin(async move { l.lock().unwrap().push("second".to_string()); })
612        }));
613
614        events.start_dispatcher(store);
615        events.post("FIRST").await;
616        events.drain().await;
617
618        assert_eq!(
619            log.lock().unwrap().as_slice(),
620            &[
621                "first:enter".to_string(),
622                "first:exit".to_string(),
623                "second".to_string(),
624            ],
625            "nested handler must queue behind the posting handler, not run inside it"
626        );
627    }
628
629    #[tokio::test]
630    async fn a_sink_may_post_another_event_without_deadlocking() {
631        // A sink registered from inside another sink's call-out. Its
632        // presence is the second half of the test: it proves the
633        // registration made during the call-out actually took effect, not
634        // just that the call-out itself returned.
635        struct LateSink {
636            fired: Arc<AtomicUsize>,
637        }
638        impl EventSink for LateSink {
639            fn on_event(&self, _event: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
640                Box::pin(async move {
641                    self.fired.fetch_add(1, Ordering::SeqCst);
642                })
643            }
644        }
645
646        struct Reposter {
647            events: Mutex<Option<std::sync::Weak<Events>>>,
648            fired: AtomicUsize,
649            late_fired: Arc<AtomicUsize>,
650        }
651        impl EventSink for Reposter {
652            fn on_event(&self, event: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
653                let event = event.to_string();
654                Box::pin(async move {
655                    if event != "OUTER" {
656                        return;
657                    }
658                    self.fired.fetch_add(1, Ordering::SeqCst);
659                    // Take and drop the guard before awaiting: a std
660                    // MutexGuard held across an await would not be Send.
661                    let ev = {
662                        let g = self.events.lock().unwrap();
663                        g.as_ref().and_then(|w| w.upgrade())
664                    };
665                    if let Some(ev) = ev {
666                        ev.post("INNER").await;
667                        // add_sink() takes the sinks lock for write. If
668                        // post() ever held the sinks read lock across this
669                        // call-out instead of cloning it first, this write
670                        // request would deadlock the calling thread against
671                        // itself deterministically — a read guard can never
672                        // be upgraded to a write guard, on any platform,
673                        // regardless of writer contention from other threads.
674                        ev.add_sink(Arc::new(LateSink {
675                            fired: self.late_fired.clone(),
676                        }));
677                    }
678                })
679            }
680        }
681
682        let events = Arc::new(Events::new());
683        let late_fired = Arc::new(AtomicUsize::new(0));
684        let sink = Arc::new(Reposter {
685            events: Mutex::new(Some(Arc::downgrade(&events))),
686            fired: AtomicUsize::new(0),
687            late_fired: late_fired.clone(),
688        });
689        events.add_sink(sink.clone());
690
691        // Would deadlock if post() held the sinks read lock across the call.
692        // Run it on its own thread (with its own runtime, since the lock
693        // deadlock would wedge the *thread*, not just the task, so an
694        // in-runtime `tokio::time::timeout` could never fire) with a bounded
695        // wait — the same discipline `drain()` applies to the dispatcher: a
696        // regression that reintroduces a lock held across the call-out must
697        // show up as a clear, named panic, not an indefinitely hanging
698        // `cargo test`.
699        const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
700        let ev = events.clone();
701        let (done_tx, done_rx) = std::sync::mpsc::channel();
702        let handle = std::thread::spawn(move || {
703            let rt = tokio::runtime::Builder::new_current_thread()
704                .build()
705                .expect("current_thread runtime for the sink call-out");
706            rt.block_on(ev.post("OUTER"));
707            let _ = done_tx.send(());
708        });
709        if done_rx.recv_timeout(CALL_TIMEOUT).is_err() {
710            panic!(
711                "sink call-out deadlocked — `post()` is holding a lock across the call-out"
712            );
713        }
714        // The thread finished; join it so a panic inside it (e.g. from the
715        // sink call-out itself) surfaces here instead of being swallowed.
716        handle.join().expect("post(\"OUTER\") thread panicked");
717
718        assert_eq!(sink.fired.load(Ordering::SeqCst), 1);
719        assert_eq!(
720            late_fired.load(Ordering::SeqCst),
721            0,
722            "late sink registered but not yet posted to"
723        );
724
725        // Probe with a fresh event name: the sink added from inside the
726        // "OUTER" call-out must now be live. (Re-posting "OUTER" itself
727        // would also re-trigger the nested "INNER" post and double-count
728        // through LateSink, so a distinct probe event keeps this precise.)
729        events.post("PROBE").await;
730        assert_eq!(
731            late_fired.load(Ordering::SeqCst),
732            1,
733            "sink registered from inside a call-out must take effect"
734        );
735    }
736}