Skip to main content

ftui_runtime/
subscription.rs

1#![forbid(unsafe_code)]
2
3//! Subscription system for continuous event sources.
4//!
5//! Subscriptions provide a declarative way to receive events from external
6//! sources like timers, file watchers, or network connections. The runtime
7//! manages subscription lifecycles automatically based on what the model
8//! declares as active.
9//!
10//! # How it works
11//!
12//! 1. `Model::subscriptions()` returns the set of active subscriptions
13//! 2. After each `update()`, the runtime compares active vs previous subscriptions
14//! 3. New subscriptions are started, removed ones are stopped
15//! 4. Subscription messages are routed through `Model::update()`
16
17use crate::cancellation::{CancellationSource, CancellationToken};
18use std::collections::HashSet;
19use std::sync::mpsc;
20use std::thread;
21use web_time::{Duration, Instant};
22
23/// A unique identifier for a subscription.
24///
25/// Used by the runtime to track which subscriptions are active and
26/// to deduplicate subscriptions across update cycles.
27pub type SubId = u64;
28
29/// A subscription produces messages from an external event source.
30///
31/// Subscriptions run on background threads and send messages through
32/// the provided channel. The runtime manages their lifecycle.
33pub trait Subscription<M: Send + 'static>: Send {
34    /// Unique identifier for deduplication.
35    ///
36    /// Subscriptions with the same ID are considered identical.
37    /// The runtime uses this to avoid restarting unchanged subscriptions.
38    fn id(&self) -> SubId;
39
40    /// Start the subscription, sending messages through the channel.
41    ///
42    /// This is called on a background thread. Implementations should
43    /// loop and send messages until the channel is disconnected (receiver dropped)
44    /// or the stop signal is received.
45    fn run(&self, sender: mpsc::Sender<M>, stop: StopSignal);
46}
47
48/// Signal for stopping a subscription.
49///
50/// When the runtime stops a subscription, it sets this signal. The subscription
51/// should check it periodically and exit its run loop when set.
52///
53/// Backed by [`CancellationToken`] for structured cancellation.
54#[derive(Clone)]
55pub struct StopSignal {
56    token: CancellationToken,
57}
58
59impl StopSignal {
60    /// Create a new stop signal pair (signal, trigger).
61    pub(crate) fn new() -> (Self, StopTrigger) {
62        let source = CancellationSource::new();
63        let signal = Self {
64            token: source.token(),
65        };
66        let trigger = StopTrigger { source };
67        (signal, trigger)
68    }
69
70    /// Check if the stop signal has been triggered.
71    pub fn is_stopped(&self) -> bool {
72        self.token.is_cancelled()
73    }
74
75    /// Wait for either the stop signal or a timeout.
76    ///
77    /// Returns `true` if stopped, `false` if timed out.
78    /// Blocks the thread efficiently using a condition variable.
79    /// Handles spurious wakeups by looping until condition met or timeout expired.
80    pub fn wait_timeout(&self, duration: Duration) -> bool {
81        self.token.wait_timeout(duration)
82    }
83
84    /// Access the underlying cancellation token.
85    ///
86    /// This enables integration with Asupersync-style structured cancellation
87    /// while preserving backwards compatibility with the `StopSignal` API.
88    pub fn cancellation_token(&self) -> &CancellationToken {
89        &self.token
90    }
91}
92
93/// Trigger to stop a subscription from the runtime side.
94///
95/// Backed by [`CancellationSource`] for structured cancellation.
96pub(crate) struct StopTrigger {
97    source: CancellationSource,
98}
99
100impl StopTrigger {
101    /// Signal the subscription to stop.
102    pub(crate) fn stop(&self) {
103        self.source.cancel();
104    }
105}
106
107/// A running subscription handle.
108pub(crate) struct RunningSubscription {
109    pub(crate) id: SubId,
110    trigger: StopTrigger,
111    thread: Option<thread::JoinHandle<()>>,
112    /// Tracks whether the subscription thread panicked (set by the catch_unwind wrapper).
113    panicked: std::sync::Arc<std::sync::atomic::AtomicBool>,
114}
115
116/// A background subscription failure waiting to be reported to the model.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub(crate) struct SubscriptionFailure {
119    pub(crate) id: SubId,
120    pub(crate) error: String,
121}
122
123const SUBSCRIPTION_STOP_JOIN_TIMEOUT: Duration = Duration::from_millis(250);
124/// Poll interval for bounded subscription thread joins (bd-1f2aw).
125///
126/// Same rationale as the executor shutdown polls: `JoinHandle` has no
127/// `join_timeout` in stable Rust, so we poll `is_finished()` with a short
128/// sleep. 1ms minimizes stop latency while avoiding spin.
129const SUBSCRIPTION_STOP_JOIN_POLL: Duration = Duration::from_millis(1);
130
131impl RunningSubscription {
132    /// Returns true if the subscription thread panicked.
133    pub(crate) fn has_panicked(&self) -> bool {
134        self.panicked.load(std::sync::atomic::Ordering::Acquire)
135    }
136
137    /// Signal the subscription to stop (phase 1 of two-phase shutdown).
138    ///
139    /// Does NOT join the thread — call [`join_bounded`] after signalling all
140    /// subscriptions to allow parallel wind-down (bd-1f2aw).
141    pub(crate) fn signal_stop(&self) {
142        self.trigger.stop();
143    }
144
145    /// Join the subscription thread with a bounded timeout (phase 2).
146    ///
147    /// Returns the join handle if the thread did not finish within the timeout,
148    /// allowing callers to log and move on without blocking indefinitely.
149    pub(crate) fn join_bounded(mut self) -> Option<thread::JoinHandle<()>> {
150        let handle = self.thread.take()?;
151        let start = Instant::now();
152
153        // Fast path: subscription already finished (common for short-lived subs).
154        if handle.is_finished() {
155            let _ = handle.join();
156            tracing::trace!(
157                sub_id = self.id,
158                panicked = self.has_panicked(),
159                elapsed_us = start.elapsed().as_micros() as u64,
160                "subscription join (fast path)"
161            );
162            return None;
163        }
164
165        // Slow path: bounded poll loop (bd-1f2aw).
166        while !handle.is_finished() {
167            if start.elapsed() >= SUBSCRIPTION_STOP_JOIN_TIMEOUT {
168                tracing::warn!(
169                    sub_id = self.id,
170                    panicked = self.has_panicked(),
171                    timeout_ms = SUBSCRIPTION_STOP_JOIN_TIMEOUT.as_millis() as u64,
172                    "subscription join timed out, detaching thread"
173                );
174                return Some(handle);
175            }
176            thread::sleep(SUBSCRIPTION_STOP_JOIN_POLL);
177        }
178
179        let _ = handle.join();
180        tracing::trace!(
181            sub_id = self.id,
182            panicked = self.has_panicked(),
183            elapsed_us = start.elapsed().as_micros() as u64,
184            "subscription join (slow path)"
185        );
186        None
187    }
188
189    /// Stop the subscription and join its thread if it exits promptly.
190    ///
191    /// Convenience method combining signal + join for single-subscription stops.
192    /// Used by tests and external callers that stop a single subscription.
193    #[cfg_attr(not(test), allow(dead_code))]
194    pub(crate) fn stop(mut self) {
195        self.trigger.stop();
196        if let Some(handle) = self.thread.take() {
197            let start = Instant::now();
198            // Fast path: subscription already finished (common for short-lived subs).
199            if handle.is_finished() {
200                let _ = handle.join();
201                tracing::trace!(
202                    sub_id = self.id,
203                    panicked = self.has_panicked(),
204                    elapsed_us = start.elapsed().as_micros() as u64,
205                    "subscription stop (fast path)"
206                );
207                return;
208            }
209            // Slow path: bounded poll loop (bd-1f2aw).
210            while !handle.is_finished() {
211                if start.elapsed() >= SUBSCRIPTION_STOP_JOIN_TIMEOUT {
212                    tracing::warn!(
213                        sub_id = self.id,
214                        panicked = self.has_panicked(),
215                        timeout_ms = SUBSCRIPTION_STOP_JOIN_TIMEOUT.as_millis() as u64,
216                        "subscription did not stop within timeout; detaching thread"
217                    );
218                    return;
219                }
220                thread::sleep(SUBSCRIPTION_STOP_JOIN_POLL);
221            }
222            let _ = handle.join();
223            tracing::trace!(
224                sub_id = self.id,
225                panicked = self.has_panicked(),
226                elapsed_us = start.elapsed().as_micros() as u64,
227                "subscription stop (slow path)"
228            );
229        }
230    }
231}
232
233impl Drop for RunningSubscription {
234    fn drop(&mut self) {
235        self.trigger.stop();
236        // Don't join in drop to avoid blocking
237    }
238}
239
240/// Manages the lifecycle of subscriptions for a program.
241pub(crate) struct SubscriptionManager<M: Send + 'static> {
242    active: Vec<RunningSubscription>,
243    sender: mpsc::Sender<M>,
244    receiver: mpsc::Receiver<M>,
245    failure_sender: mpsc::Sender<SubscriptionFailure>,
246    failure_receiver: mpsc::Receiver<SubscriptionFailure>,
247}
248
249impl<M: Send + 'static> SubscriptionManager<M> {
250    pub(crate) fn new() -> Self {
251        let (sender, receiver) = mpsc::channel();
252        let (failure_sender, failure_receiver) = mpsc::channel();
253        Self {
254            active: Vec::new(),
255            sender,
256            receiver,
257            failure_sender,
258            failure_receiver,
259        }
260    }
261
262    /// Update the set of active subscriptions.
263    ///
264    /// Compares the new set against currently running subscriptions:
265    /// - Starts subscriptions that are new (ID not in active set)
266    /// - Stops subscriptions that are no longer declared (ID not in new set)
267    /// - Leaves unchanged subscriptions running
268    pub(crate) fn reconcile(&mut self, subscriptions: Vec<Box<dyn Subscription<M>>>) {
269        let reconcile_start = Instant::now();
270        let new_ids: HashSet<SubId> = subscriptions.iter().map(|s| s.id()).collect();
271        let active_count_before = self.active.len();
272
273        crate::debug_trace!(
274            "reconcile: new_ids={:?}, active_before={}",
275            new_ids,
276            active_count_before
277        );
278        tracing::trace!(
279            new_id_count = new_ids.len(),
280            active_before = active_count_before,
281            new_ids = ?new_ids,
282            "subscription reconcile starting"
283        );
284
285        // Stop subscriptions that are no longer active (two-phase: bd-1f2aw).
286        let mut remaining = Vec::new();
287        let mut to_stop = Vec::new();
288        for running in self.active.drain(..) {
289            if new_ids.contains(&running.id) {
290                remaining.push(running);
291            } else {
292                crate::debug_trace!("stopping subscription: id={}", running.id);
293                tracing::debug!(sub_id = running.id, "Stopping subscription");
294                crate::effect_system::record_subscription_stop("subscription", running.id, 0);
295                crate::effect_system::record_dynamics_sub_stop();
296                to_stop.push(running);
297            }
298        }
299        // Phase 1: Signal all removals.
300        for running in &to_stop {
301            running.signal_stop();
302        }
303        let stopped_count = to_stop.len();
304        // Phase 2: Join with bounded timeout.
305        for running in to_stop {
306            let _ = running.join_bounded();
307        }
308        self.active = remaining;
309
310        // Start new subscriptions
311        let mut started_count = 0usize;
312        let mut active_ids: HashSet<SubId> = self.active.iter().map(|r| r.id).collect();
313        for sub in subscriptions {
314            let id = sub.id();
315            if !active_ids.insert(id) {
316                continue;
317            }
318            started_count += 1;
319
320            crate::debug_trace!("starting subscription: id={}", id);
321            tracing::debug!(sub_id = id, "Starting subscription");
322            crate::effect_system::record_subscription_start("subscription", id);
323            crate::effect_system::record_dynamics_sub_start();
324            let (signal, trigger) = StopSignal::new();
325            let sender = self.sender.clone();
326            let panicked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
327            let panicked_flag = panicked.clone();
328            let sub_id_for_thread = id;
329            let failure_sender = self.failure_sender.clone();
330
331            let thread = thread::spawn(move || {
332                // Recoverable boundary: a panicking subscription is recorded
333                // and the program continues. Suppress the terminal panic
334                // hook so it doesn't tear down live terminal state mid-run.
335                let result = ftui_core::with_panic_cleanup_suppressed(|| {
336                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
337                        sub.run(sender, signal);
338                    }))
339                });
340                if let Err(payload) = result {
341                    panicked_flag.store(true, std::sync::atomic::Ordering::Release);
342                    crate::effect_system::record_dynamics_sub_panic();
343                    let panic_msg = match payload.downcast_ref::<&str>() {
344                        Some(s) => (*s).to_string(),
345                        None => match payload.downcast_ref::<String>() {
346                            Some(s) => s.clone(),
347                            None => "unknown panic payload".to_string(),
348                        },
349                    };
350                    crate::effect_system::error_effect_panic(
351                        "subscription",
352                        &format!("sub_id={sub_id_for_thread}: {panic_msg}"),
353                    );
354                    let _ = failure_sender.send(SubscriptionFailure {
355                        id: sub_id_for_thread,
356                        error: panic_msg,
357                    });
358                }
359            });
360
361            self.active.push(RunningSubscription {
362                id,
363                trigger,
364                thread: Some(thread),
365                panicked,
366            });
367        }
368
369        let active_count_after = self.active.len();
370        let reconcile_elapsed_us = reconcile_start.elapsed().as_micros() as u64;
371        crate::effect_system::record_dynamics_reconcile(reconcile_elapsed_us);
372        crate::debug_trace!("reconcile complete: active_after={}", active_count_after);
373        tracing::trace!(
374            active_before = active_count_before,
375            active_after = active_count_after,
376            started = started_count,
377            stopped = stopped_count,
378            reconcile_us = reconcile_elapsed_us,
379            "subscription reconcile complete"
380        );
381    }
382
383    /// Drain pending messages from subscriptions.
384    pub(crate) fn drain_messages(&self) -> Vec<M> {
385        let mut messages = Vec::new();
386        while let Ok(msg) = self.receiver.try_recv() {
387            messages.push(msg);
388        }
389        messages
390    }
391
392    /// Drain subscription failures that have not yet been reported to the model.
393    pub(crate) fn drain_failures(&self) -> Vec<SubscriptionFailure> {
394        let mut failures = Vec::new();
395        while let Ok(failure) = self.failure_receiver.try_recv() {
396            failures.push(failure);
397        }
398        failures
399    }
400
401    /// Return the number of active subscriptions.
402    #[inline]
403    pub(crate) fn active_count(&self) -> usize {
404        self.active.len()
405    }
406
407    /// Stop all running subscriptions using two-phase parallel shutdown (bd-1f2aw).
408    ///
409    /// Phase 1: Signal all subscriptions to stop (non-blocking).
410    /// Phase 2: Join all threads with bounded timeout.
411    ///
412    /// This is significantly faster than sequential stop when multiple
413    /// subscriptions are active, because all threads begin winding down
414    /// simultaneously rather than waiting for each to finish in turn.
415    pub(crate) fn stop_all(&mut self) {
416        let count = self.active.len();
417        if count == 0 {
418            return;
419        }
420        let start = Instant::now();
421
422        // Phase 1: Signal all subscriptions to stop (parallel).
423        for running in &self.active {
424            running.signal_stop();
425        }
426
427        let signal_elapsed_us = start.elapsed().as_micros() as u64;
428        tracing::trace!(
429            target: "ftui.runtime",
430            count,
431            signal_elapsed_us,
432            "subscription stop_all phase 1 (signal) complete"
433        );
434
435        // Phase 2: Join all threads with bounded timeout.
436        let mut panicked_count = 0_usize;
437        let mut timed_out_count = 0_usize;
438        for running in self.active.drain(..) {
439            if running.has_panicked() {
440                panicked_count += 1;
441            }
442            if running.join_bounded().is_some() {
443                timed_out_count += 1;
444            }
445        }
446
447        let shutdown_elapsed_us = start.elapsed().as_micros() as u64;
448        crate::effect_system::record_dynamics_shutdown(shutdown_elapsed_us, timed_out_count as u64);
449        tracing::debug!(
450            target: "ftui.runtime",
451            count,
452            panicked_count,
453            timed_out_count,
454            elapsed_us = shutdown_elapsed_us,
455            "subscription stop_all complete"
456        );
457    }
458}
459
460impl<M: Send + 'static> Drop for SubscriptionManager<M> {
461    fn drop(&mut self) {
462        self.stop_all();
463    }
464}
465
466// --- Built-in subscriptions ---
467
468/// A subscription that fires at a fixed interval.
469///
470/// # Example
471///
472/// ```ignore
473/// fn subscriptions(&self) -> Vec<Box<dyn Subscription<MyMsg>>> {
474///     vec![Box::new(Every::new(Duration::from_secs(1), || MyMsg::Tick))]
475/// }
476/// ```
477pub struct Every<M: Send + 'static> {
478    id: SubId,
479    interval: Duration,
480    make_msg: Box<dyn Fn() -> M + Send + Sync>,
481}
482
483impl<M: Send + 'static> Every<M> {
484    /// Create a tick subscription with the given interval and message factory.
485    pub fn new(interval: Duration, make_msg: impl Fn() -> M + Send + Sync + 'static) -> Self {
486        // Generate a stable ID from the interval to allow deduplication
487        let id = interval.as_nanos() as u64 ^ 0x5449_434B; // "TICK" magic
488        Self {
489            id,
490            interval,
491            make_msg: Box::new(make_msg),
492        }
493    }
494
495    /// Create a tick subscription with an explicit ID.
496    pub fn with_id(
497        id: SubId,
498        interval: Duration,
499        make_msg: impl Fn() -> M + Send + Sync + 'static,
500    ) -> Self {
501        Self {
502            id,
503            interval,
504            make_msg: Box::new(make_msg),
505        }
506    }
507}
508
509impl<M: Send + 'static> Subscription<M> for Every<M> {
510    fn id(&self) -> SubId {
511        self.id
512    }
513
514    fn run(&self, sender: mpsc::Sender<M>, stop: StopSignal) {
515        let mut tick_count: u64 = 0;
516        crate::debug_trace!(
517            "Every subscription started: id={}, interval={:?}",
518            self.id,
519            self.interval
520        );
521        loop {
522            if stop.wait_timeout(self.interval) {
523                crate::debug_trace!(
524                    "Every subscription stopped: id={}, sent {} ticks",
525                    self.id,
526                    tick_count
527                );
528                break;
529            }
530            tick_count += 1;
531            let msg = (self.make_msg)();
532            if sender.send(msg).is_err() {
533                crate::debug_trace!(
534                    "Every subscription channel closed: id={}, sent {} ticks",
535                    self.id,
536                    tick_count
537                );
538                break;
539            }
540        }
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[derive(Debug, Clone, PartialEq)]
549    enum TestMsg {
550        Tick,
551        Value(i32),
552    }
553
554    struct ChannelSubscription<M: Send + 'static> {
555        id: SubId,
556        receiver: mpsc::Receiver<M>,
557        poll: Duration,
558    }
559
560    impl<M: Send + 'static> ChannelSubscription<M> {
561        fn new(id: SubId, receiver: mpsc::Receiver<M>) -> Self {
562            Self {
563                id,
564                receiver,
565                poll: Duration::from_millis(5),
566            }
567        }
568    }
569
570    impl<M: Send + 'static> Subscription<M> for ChannelSubscription<M> {
571        fn id(&self) -> SubId {
572            self.id
573        }
574
575        fn run(&self, sender: mpsc::Sender<M>, stop: StopSignal) {
576            loop {
577                if stop.is_stopped() {
578                    break;
579                }
580                match self.receiver.recv_timeout(self.poll) {
581                    Ok(msg) => {
582                        if sender.send(msg).is_err() {
583                            break;
584                        }
585                    }
586                    Err(mpsc::RecvTimeoutError::Timeout) => {}
587                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
588                }
589            }
590        }
591    }
592
593    fn channel_subscription(id: SubId) -> (ChannelSubscription<TestMsg>, mpsc::Sender<TestMsg>) {
594        let (tx, rx) = mpsc::channel();
595        (ChannelSubscription::new(id, rx), tx)
596    }
597
598    #[test]
599    fn stop_signal_starts_false() {
600        let (signal, _trigger) = StopSignal::new();
601        assert!(!signal.is_stopped());
602    }
603
604    #[test]
605    fn stop_signal_becomes_true_after_trigger() {
606        let (signal, trigger) = StopSignal::new();
607        trigger.stop();
608        assert!(signal.is_stopped());
609    }
610
611    #[test]
612    fn stop_signal_wait_returns_true_when_stopped() {
613        let (signal, trigger) = StopSignal::new();
614        trigger.stop();
615        assert!(signal.wait_timeout(Duration::from_millis(100)));
616    }
617
618    #[test]
619    fn stop_signal_wait_returns_false_on_timeout() {
620        let (signal, _trigger) = StopSignal::new();
621        assert!(!signal.wait_timeout(Duration::from_millis(10)));
622    }
623
624    #[test]
625    fn channel_subscription_forwards_messages() {
626        let (sub, event_tx) = channel_subscription(1);
627        let (tx, rx) = mpsc::channel();
628        let (signal, trigger) = StopSignal::new();
629
630        let handle = thread::spawn(move || {
631            sub.run(tx, signal);
632        });
633
634        event_tx.send(TestMsg::Value(1)).unwrap();
635        event_tx.send(TestMsg::Value(2)).unwrap();
636        thread::sleep(Duration::from_millis(10));
637        trigger.stop();
638        handle.join().unwrap();
639
640        let msgs: Vec<_> = rx.try_iter().collect();
641        assert_eq!(msgs, vec![TestMsg::Value(1), TestMsg::Value(2)]);
642    }
643
644    #[test]
645    fn every_subscription_fires() {
646        let sub = Every::new(Duration::from_millis(10), || TestMsg::Tick);
647        let (tx, rx) = mpsc::channel();
648        let (signal, trigger) = StopSignal::new();
649
650        let handle = thread::spawn(move || {
651            sub.run(tx, signal);
652        });
653
654        // Wait for a few ticks
655        thread::sleep(Duration::from_millis(50));
656        trigger.stop();
657        handle.join().unwrap();
658
659        let msgs: Vec<_> = rx.try_iter().collect();
660        assert!(!msgs.is_empty(), "Should have received at least one tick");
661        assert!(msgs.iter().all(|m| *m == TestMsg::Tick));
662    }
663
664    #[test]
665    fn every_subscription_uses_stable_id() {
666        let sub1 = Every::<TestMsg>::new(Duration::from_secs(1), || TestMsg::Tick);
667        let sub2 = Every::<TestMsg>::new(Duration::from_secs(1), || TestMsg::Tick);
668        assert_eq!(sub1.id(), sub2.id());
669    }
670
671    #[test]
672    fn every_subscription_different_intervals_different_ids() {
673        let sub1 = Every::<TestMsg>::new(Duration::from_secs(1), || TestMsg::Tick);
674        let sub2 = Every::<TestMsg>::new(Duration::from_secs(2), || TestMsg::Tick);
675        assert_ne!(sub1.id(), sub2.id());
676    }
677
678    #[test]
679    fn subscription_manager_starts_subscriptions() {
680        let mut mgr = SubscriptionManager::<TestMsg>::new();
681        let (sub, event_tx) = channel_subscription(1);
682        let subs: Vec<Box<dyn Subscription<TestMsg>>> = vec![Box::new(sub)];
683
684        mgr.reconcile(subs);
685        event_tx.send(TestMsg::Value(42)).unwrap();
686
687        // Give the thread a moment to send
688        thread::sleep(Duration::from_millis(20));
689
690        let msgs = mgr.drain_messages();
691        assert_eq!(msgs, vec![TestMsg::Value(42)]);
692    }
693
694    #[test]
695    fn subscription_manager_dedupes_duplicate_ids() {
696        let mut mgr = SubscriptionManager::<TestMsg>::new();
697        let (sub_a, tx_a) = channel_subscription(7);
698        let (sub_b, tx_b) = channel_subscription(7);
699        let subs: Vec<Box<dyn Subscription<TestMsg>>> = vec![Box::new(sub_a), Box::new(sub_b)];
700
701        mgr.reconcile(subs);
702
703        tx_a.send(TestMsg::Value(1)).unwrap();
704        assert!(
705            tx_b.send(TestMsg::Value(2)).is_err(),
706            "Duplicate subscription should be dropped"
707        );
708
709        thread::sleep(Duration::from_millis(20));
710        let msgs = mgr.drain_messages();
711        assert_eq!(msgs, vec![TestMsg::Value(1)]);
712    }
713
714    #[test]
715    fn subscription_manager_stops_removed() {
716        let mut mgr = SubscriptionManager::<TestMsg>::new();
717
718        // Start with one subscription
719        mgr.reconcile(vec![Box::new(Every::with_id(
720            99,
721            Duration::from_millis(5),
722            || TestMsg::Tick,
723        ))]);
724
725        thread::sleep(Duration::from_millis(20));
726        let msgs_before = mgr.drain_messages();
727        assert!(!msgs_before.is_empty());
728
729        // Remove it
730        mgr.reconcile(vec![]);
731
732        // Drain any remaining buffered messages
733        thread::sleep(Duration::from_millis(20));
734        let _ = mgr.drain_messages();
735
736        // After stopping, no more messages should arrive
737        thread::sleep(Duration::from_millis(30));
738        let msgs_after = mgr.drain_messages();
739        assert!(
740            msgs_after.is_empty(),
741            "Should stop receiving after reconcile with empty set"
742        );
743    }
744
745    #[test]
746    fn subscription_manager_keeps_unchanged() {
747        let mut mgr = SubscriptionManager::<TestMsg>::new();
748
749        // Start subscription
750        mgr.reconcile(vec![Box::new(Every::with_id(
751            50,
752            Duration::from_millis(10),
753            || TestMsg::Tick,
754        ))]);
755
756        thread::sleep(Duration::from_millis(30));
757        let _ = mgr.drain_messages();
758
759        // Reconcile with same ID - should keep running
760        mgr.reconcile(vec![Box::new(Every::with_id(
761            50,
762            Duration::from_millis(10),
763            || TestMsg::Tick,
764        ))]);
765
766        thread::sleep(Duration::from_millis(30));
767        let msgs = mgr.drain_messages();
768        assert!(!msgs.is_empty(), "Subscription should still be running");
769    }
770
771    #[test]
772    fn subscription_manager_stop_all() {
773        let mut mgr = SubscriptionManager::<TestMsg>::new();
774
775        mgr.reconcile(vec![
776            Box::new(Every::with_id(1, Duration::from_millis(5), || {
777                TestMsg::Value(1)
778            })),
779            Box::new(Every::with_id(2, Duration::from_millis(5), || {
780                TestMsg::Value(2)
781            })),
782        ]);
783
784        thread::sleep(Duration::from_millis(20));
785        mgr.stop_all();
786
787        thread::sleep(Duration::from_millis(20));
788        let _ = mgr.drain_messages();
789        thread::sleep(Duration::from_millis(30));
790        let msgs = mgr.drain_messages();
791        assert!(msgs.is_empty());
792    }
793
794    // =========================================================================
795    // ADDITIONAL TESTS - Cmd sequencing + Subscriptions (bd-2nu8.10.2)
796    // =========================================================================
797
798    #[test]
799    fn stop_signal_is_cloneable() {
800        let (signal, trigger) = StopSignal::new();
801        let signal_clone = signal.clone();
802
803        assert!(!signal.is_stopped());
804        assert!(!signal_clone.is_stopped());
805
806        trigger.stop();
807
808        assert!(signal.is_stopped());
809        assert!(signal_clone.is_stopped());
810    }
811
812    #[test]
813    fn stop_signal_wait_wakes_immediately_when_already_stopped() {
814        let (signal, trigger) = StopSignal::new();
815        trigger.stop();
816
817        // Should return immediately, not wait for timeout
818        let start = Instant::now();
819        let stopped = signal.wait_timeout(Duration::from_secs(10));
820        let elapsed = start.elapsed();
821
822        assert!(stopped);
823        assert!(elapsed < Duration::from_millis(100));
824    }
825
826    #[test]
827    fn stop_signal_wait_is_interrupted_by_trigger() {
828        let (signal, trigger) = StopSignal::new();
829
830        let signal_clone = signal.clone();
831        let handle = thread::spawn(move || signal_clone.wait_timeout(Duration::from_secs(10)));
832
833        // Give thread time to start waiting
834        thread::sleep(Duration::from_millis(20));
835        trigger.stop();
836
837        let stopped = handle.join().unwrap();
838        assert!(stopped);
839    }
840
841    #[test]
842    fn channel_subscription_no_messages_without_events() {
843        let (sub, _event_tx) = channel_subscription(1);
844        let (tx, rx) = mpsc::channel();
845        let (signal, trigger) = StopSignal::new();
846
847        let handle = thread::spawn(move || {
848            sub.run(tx, signal);
849        });
850
851        thread::sleep(Duration::from_millis(10));
852        trigger.stop();
853        handle.join().unwrap();
854
855        let msgs: Vec<_> = rx.try_iter().collect();
856        assert!(msgs.is_empty());
857    }
858
859    #[test]
860    fn channel_subscription_id_is_preserved() {
861        let (sub, _tx) = channel_subscription(42);
862        assert_eq!(sub.id(), 42);
863    }
864
865    #[test]
866    fn channel_subscription_stops_on_disconnected_receiver() {
867        let (sub, event_tx) = channel_subscription(1);
868        let (tx, _rx) = mpsc::channel();
869        let (signal, _trigger) = StopSignal::new();
870
871        drop(event_tx);
872
873        let handle = thread::spawn(move || {
874            sub.run(tx, signal);
875        });
876
877        let result = handle.join();
878        assert!(result.is_ok());
879    }
880
881    #[test]
882    fn every_with_id_preserves_custom_id() {
883        let sub = Every::<TestMsg>::with_id(12345, Duration::from_secs(1), || TestMsg::Tick);
884        assert_eq!(sub.id(), 12345);
885    }
886
887    #[test]
888    fn every_stops_on_disconnected_receiver() {
889        let sub = Every::new(Duration::from_millis(5), || TestMsg::Tick);
890        let (tx, rx) = mpsc::channel();
891        let (signal, _trigger) = StopSignal::new();
892
893        // Drop receiver before running
894        drop(rx);
895
896        // Should exit the loop when send fails
897        let handle = thread::spawn(move || {
898            sub.run(tx, signal);
899        });
900
901        // Should complete quickly, not hang
902        let result = handle.join();
903        assert!(result.is_ok());
904    }
905
906    #[test]
907    fn every_respects_interval() {
908        let sub = Every::with_id(1, Duration::from_millis(50), || TestMsg::Tick);
909        let (tx, rx) = mpsc::channel();
910        let (signal, trigger) = StopSignal::new();
911
912        let start = Instant::now();
913        let handle = thread::spawn(move || {
914            sub.run(tx, signal);
915        });
916
917        // Wait for 3 ticks worth of time
918        thread::sleep(Duration::from_millis(160));
919        trigger.stop();
920        handle.join().unwrap();
921
922        let msgs: Vec<_> = rx.try_iter().collect();
923        let elapsed = start.elapsed();
924
925        // Should have approximately 3 ticks (at 50ms intervals over 160ms)
926        assert!(
927            msgs.len() >= 2,
928            "Expected at least 2 ticks, got {}",
929            msgs.len()
930        );
931        assert!(
932            msgs.len() <= 4,
933            "Expected at most 4 ticks, got {}",
934            msgs.len()
935        );
936        assert!(elapsed >= Duration::from_millis(150));
937    }
938
939    #[test]
940    fn subscription_manager_empty_reconcile() {
941        let mut mgr = SubscriptionManager::<TestMsg>::new();
942
943        // Reconcile with empty list should not panic
944        mgr.reconcile(vec![]);
945        let msgs = mgr.drain_messages();
946        assert!(msgs.is_empty());
947    }
948
949    #[test]
950    fn subscription_manager_drain_messages_returns_all() {
951        let mut mgr = SubscriptionManager::<TestMsg>::new();
952        let (sub, event_tx) = channel_subscription(1);
953        let subs: Vec<Box<dyn Subscription<TestMsg>>> = vec![Box::new(sub)];
954
955        mgr.reconcile(subs);
956        event_tx.send(TestMsg::Value(1)).unwrap();
957        event_tx.send(TestMsg::Value(2)).unwrap();
958        thread::sleep(Duration::from_millis(20));
959
960        let msgs = mgr.drain_messages();
961        assert_eq!(msgs.len(), 2);
962        assert_eq!(msgs[0], TestMsg::Value(1));
963        assert_eq!(msgs[1], TestMsg::Value(2));
964
965        // Second drain should be empty
966        let msgs2 = mgr.drain_messages();
967        assert!(msgs2.is_empty());
968    }
969
970    #[test]
971    fn subscription_manager_replaces_subscription_with_different_id() {
972        let mut mgr = SubscriptionManager::<TestMsg>::new();
973        let (sub1, tx1) = channel_subscription(1);
974
975        // Start with ID 1
976        mgr.reconcile(vec![Box::new(sub1)]);
977        tx1.send(TestMsg::Value(1)).unwrap();
978        thread::sleep(Duration::from_millis(20));
979        let msgs1 = mgr.drain_messages();
980        assert_eq!(msgs1, vec![TestMsg::Value(1)]);
981
982        // Replace with ID 2
983        let (sub2, tx2) = channel_subscription(2);
984        mgr.reconcile(vec![Box::new(sub2)]);
985        tx2.send(TestMsg::Value(2)).unwrap();
986        thread::sleep(Duration::from_millis(20));
987        let msgs2 = mgr.drain_messages();
988        assert_eq!(msgs2, vec![TestMsg::Value(2)]);
989    }
990
991    #[test]
992    fn subscription_manager_multiple_subscriptions() {
993        let mut mgr = SubscriptionManager::<TestMsg>::new();
994        let (sub1, tx1) = channel_subscription(1);
995        let (sub2, tx2) = channel_subscription(2);
996        let (sub3, tx3) = channel_subscription(3);
997        let subs: Vec<Box<dyn Subscription<TestMsg>>> =
998            vec![Box::new(sub1), Box::new(sub2), Box::new(sub3)];
999
1000        mgr.reconcile(subs);
1001        tx1.send(TestMsg::Value(10)).unwrap();
1002        tx2.send(TestMsg::Value(20)).unwrap();
1003        tx3.send(TestMsg::Value(30)).unwrap();
1004        thread::sleep(Duration::from_millis(30));
1005
1006        let mut msgs = mgr.drain_messages();
1007        msgs.sort_by_key(|m| match m {
1008            TestMsg::Value(v) => *v,
1009            _ => 0,
1010        });
1011
1012        assert_eq!(msgs.len(), 3);
1013        assert_eq!(msgs[0], TestMsg::Value(10));
1014        assert_eq!(msgs[1], TestMsg::Value(20));
1015        assert_eq!(msgs[2], TestMsg::Value(30));
1016    }
1017
1018    #[test]
1019    fn subscription_manager_partial_update() {
1020        let mut mgr = SubscriptionManager::<TestMsg>::new();
1021
1022        // Start with 3 subscriptions
1023        mgr.reconcile(vec![
1024            Box::new(Every::with_id(1, Duration::from_millis(10), || {
1025                TestMsg::Value(1)
1026            })),
1027            Box::new(Every::with_id(2, Duration::from_millis(10), || {
1028                TestMsg::Value(2)
1029            })),
1030            Box::new(Every::with_id(3, Duration::from_millis(10), || {
1031                TestMsg::Value(3)
1032            })),
1033        ]);
1034
1035        thread::sleep(Duration::from_millis(30));
1036        let _ = mgr.drain_messages();
1037
1038        // Remove subscription 2, keep 1 and 3
1039        mgr.reconcile(vec![
1040            Box::new(Every::with_id(1, Duration::from_millis(10), || {
1041                TestMsg::Value(1)
1042            })),
1043            Box::new(Every::with_id(3, Duration::from_millis(10), || {
1044                TestMsg::Value(3)
1045            })),
1046        ]);
1047
1048        // Drain any in-flight messages that were sent before the stop signal was processed.
1049        // This clears the race window between stop signal and message send.
1050        let _ = mgr.drain_messages();
1051
1052        // Now wait for new messages from the remaining subscriptions
1053        thread::sleep(Duration::from_millis(30));
1054        let msgs = mgr.drain_messages();
1055
1056        // Should only have values 1 and 3, not 2
1057        let values: Vec<i32> = msgs
1058            .iter()
1059            .filter_map(|m| match m {
1060                TestMsg::Value(v) => Some(*v),
1061                _ => None,
1062            })
1063            .collect();
1064
1065        assert!(
1066            values.contains(&1),
1067            "Should still receive from subscription 1"
1068        );
1069        assert!(
1070            values.contains(&3),
1071            "Should still receive from subscription 3"
1072        );
1073        assert!(
1074            !values.contains(&2),
1075            "Should not receive from stopped subscription 2"
1076        );
1077    }
1078
1079    #[test]
1080    fn subscription_manager_drop_stops_all() {
1081        let (_signal, _) = StopSignal::new();
1082        let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1083        let flag_clone = flag.clone();
1084
1085        struct FlagSubscription {
1086            id: SubId,
1087            flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
1088        }
1089
1090        impl Subscription<TestMsg> for FlagSubscription {
1091            fn id(&self) -> SubId {
1092                self.id
1093            }
1094
1095            fn run(&self, _sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1096                while !stop.is_stopped() {
1097                    thread::sleep(Duration::from_millis(5));
1098                }
1099                self.flag.store(true, std::sync::atomic::Ordering::SeqCst);
1100            }
1101        }
1102
1103        {
1104            let mut mgr = SubscriptionManager::<TestMsg>::new();
1105            mgr.reconcile(vec![Box::new(FlagSubscription {
1106                id: 1,
1107                flag: flag_clone,
1108            })]);
1109
1110            thread::sleep(Duration::from_millis(20));
1111            // mgr drops here, should stop all subscriptions
1112        }
1113
1114        thread::sleep(Duration::from_millis(50));
1115        assert!(
1116            flag.load(std::sync::atomic::Ordering::SeqCst),
1117            "Subscription should have stopped on drop"
1118        );
1119    }
1120
1121    #[test]
1122    fn running_subscription_stop_joins_thread() {
1123        use std::sync::atomic::{AtomicBool, Ordering};
1124
1125        let completed = std::sync::Arc::new(AtomicBool::new(false));
1126        let completed_clone = completed.clone();
1127
1128        let (signal, trigger) = StopSignal::new();
1129        let (_tx, _rx) = mpsc::channel::<TestMsg>();
1130
1131        let thread = thread::spawn(move || {
1132            while !signal.is_stopped() {
1133                thread::sleep(Duration::from_millis(5));
1134            }
1135            completed_clone.store(true, Ordering::SeqCst);
1136        });
1137
1138        let running = RunningSubscription {
1139            id: 1,
1140            trigger,
1141            thread: Some(thread),
1142            panicked: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1143        };
1144
1145        running.stop();
1146        assert!(completed.load(Ordering::SeqCst));
1147    }
1148
1149    #[test]
1150    fn running_subscription_stop_times_out_for_uncooperative_thread() {
1151        use std::sync::atomic::{AtomicBool, Ordering};
1152
1153        let completed = std::sync::Arc::new(AtomicBool::new(false));
1154        let completed_clone = completed.clone();
1155
1156        let (_signal, trigger) = StopSignal::new();
1157        let thread = thread::spawn(move || {
1158            thread::sleep(Duration::from_millis(500));
1159            completed_clone.store(true, Ordering::SeqCst);
1160        });
1161
1162        let running = RunningSubscription {
1163            id: 7,
1164            trigger,
1165            thread: Some(thread),
1166            panicked: std::sync::Arc::new(AtomicBool::new(false)),
1167        };
1168
1169        let start = Instant::now();
1170        running.stop();
1171        assert!(
1172            start.elapsed() < Duration::from_millis(400),
1173            "stop() should not block behind an uncooperative subscription thread"
1174        );
1175
1176        thread::sleep(Duration::from_millis(550));
1177        assert!(completed.load(Ordering::SeqCst));
1178    }
1179
1180    #[test]
1181    fn every_id_stable_across_instances() {
1182        // Same interval should produce same ID
1183        let sub1 = Every::<TestMsg>::new(Duration::from_millis(100), || TestMsg::Tick);
1184        let sub2 = Every::<TestMsg>::new(Duration::from_millis(100), || TestMsg::Tick);
1185        let sub3 = Every::<TestMsg>::new(Duration::from_millis(100), || TestMsg::Value(1));
1186
1187        assert_eq!(sub1.id(), sub2.id());
1188        assert_eq!(sub2.id(), sub3.id()); // ID is based on interval, not message factory
1189    }
1190
1191    #[test]
1192    fn drain_messages_preserves_order() {
1193        let mut mgr = SubscriptionManager::<TestMsg>::new();
1194
1195        // Use a custom subscription that sends messages in order
1196        struct OrderedSubscription {
1197            values: Vec<i32>,
1198        }
1199
1200        impl Subscription<TestMsg> for OrderedSubscription {
1201            fn id(&self) -> SubId {
1202                999
1203            }
1204
1205            fn run(&self, sender: mpsc::Sender<TestMsg>, _stop: StopSignal) {
1206                for v in &self.values {
1207                    let _ = sender.send(TestMsg::Value(*v));
1208                    thread::sleep(Duration::from_millis(1));
1209                }
1210            }
1211        }
1212
1213        mgr.reconcile(vec![Box::new(OrderedSubscription {
1214            values: vec![1, 2, 3, 4, 5],
1215        })]);
1216
1217        thread::sleep(Duration::from_millis(30));
1218        let msgs = mgr.drain_messages();
1219
1220        let values: Vec<i32> = msgs
1221            .iter()
1222            .filter_map(|m| match m {
1223                TestMsg::Value(v) => Some(*v),
1224                _ => None,
1225            })
1226            .collect();
1227
1228        assert_eq!(values, vec![1, 2, 3, 4, 5]);
1229    }
1230
1231    #[test]
1232    fn subscription_manager_new_is_empty() {
1233        let mgr = SubscriptionManager::<TestMsg>::new();
1234        let msgs = mgr.drain_messages();
1235        assert!(msgs.is_empty());
1236    }
1237
1238    // =========================================================================
1239    // LIFECYCLE CONTRACT TESTS (bd-1dg21)
1240    //
1241    // These tests capture the observable behavioral contract of the subscription
1242    // system that MUST be preserved during the Asupersync migration. Each test
1243    // documents a specific guarantee that downstream code relies on.
1244    // =========================================================================
1245
1246    /// CONTRACT: StopSignal backed by CancellationToken must remain functional
1247    /// even after concurrent thread panics. The AtomicBool-based implementation
1248    /// is inherently poison-resistant.
1249    #[test]
1250    fn contract_stop_signal_resilient_to_thread_panics() {
1251        let (signal, trigger) = StopSignal::new();
1252        let signal_clone = signal.clone();
1253
1254        // Panic in a thread that holds a clone of the signal
1255        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1256            assert!(!signal_clone.is_stopped());
1257            panic!("intentional panic while holding signal clone");
1258        }));
1259        assert!(result.is_err());
1260
1261        // Signal must still be checkable and triggerable after thread panic
1262        assert!(
1263            !signal.is_stopped(),
1264            "signal should still report not-stopped"
1265        );
1266        trigger.stop();
1267        assert!(
1268            signal.is_stopped(),
1269            "signal should report stopped after trigger"
1270        );
1271        assert!(
1272            signal.wait_timeout(Duration::from_millis(10)),
1273            "wait_timeout should return true when stopped"
1274        );
1275    }
1276
1277    /// CONTRACT: StopSignal exposes its underlying CancellationToken for
1278    /// Asupersync integration.
1279    #[test]
1280    fn contract_stop_signal_exposes_cancellation_token() {
1281        let (signal, trigger) = StopSignal::new();
1282        let token = signal.cancellation_token();
1283        assert!(!token.is_cancelled(), "token should start uncancelled");
1284        trigger.stop();
1285        assert!(token.is_cancelled(), "token should be cancelled after stop");
1286    }
1287
1288    /// CONTRACT: stop_all() must complete within a bounded time even if subscription
1289    /// threads are uncooperative. The 250ms join timeout per subscription is the
1290    /// upper bound.
1291    #[test]
1292    fn contract_stop_all_bounded_time_with_uncooperative_subscriptions() {
1293        let mut mgr = SubscriptionManager::<TestMsg>::new();
1294
1295        // Create subscriptions that ignore the stop signal
1296        struct UncooperativeSub {
1297            id: SubId,
1298        }
1299
1300        impl Subscription<TestMsg> for UncooperativeSub {
1301            fn id(&self) -> SubId {
1302                self.id
1303            }
1304
1305            fn run(&self, _sender: mpsc::Sender<TestMsg>, _stop: StopSignal) {
1306                // Ignore stop signal entirely, sleep for a long time
1307                thread::sleep(Duration::from_secs(5));
1308            }
1309        }
1310
1311        mgr.reconcile(vec![
1312            Box::new(UncooperativeSub { id: 100 }),
1313            Box::new(UncooperativeSub { id: 200 }),
1314        ]);
1315
1316        thread::sleep(Duration::from_millis(20)); // let threads start
1317
1318        let start = Instant::now();
1319        mgr.stop_all();
1320        let elapsed = start.elapsed();
1321
1322        // 2 subscriptions * 250ms timeout each = 500ms max, plus some margin
1323        assert!(
1324            elapsed < Duration::from_millis(800),
1325            "stop_all took {elapsed:?}, expected < 800ms for 2 uncooperative subscriptions"
1326        );
1327    }
1328
1329    /// CONTRACT: reconcile() must not start a new subscription for an ID that is
1330    /// already active, even if the subscription object is different.
1331    #[test]
1332    fn contract_reconcile_deduplicates_by_id_not_identity() {
1333        let mut mgr = SubscriptionManager::<TestMsg>::new();
1334        let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1335
1336        struct CountingSub {
1337            id: SubId,
1338            counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1339        }
1340
1341        impl Subscription<TestMsg> for CountingSub {
1342            fn id(&self) -> SubId {
1343                self.id
1344            }
1345
1346            fn run(&self, _sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1347                self.counter
1348                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1349                while !stop.is_stopped() {
1350                    thread::sleep(Duration::from_millis(5));
1351                }
1352            }
1353        }
1354
1355        // First reconcile starts one thread
1356        mgr.reconcile(vec![Box::new(CountingSub {
1357            id: 42,
1358            counter: counter.clone(),
1359        })]);
1360        thread::sleep(Duration::from_millis(20));
1361        assert_eq!(
1362            counter.load(std::sync::atomic::Ordering::SeqCst),
1363            1,
1364            "first reconcile should start exactly 1 thread"
1365        );
1366
1367        // Second reconcile with same ID must NOT start another thread
1368        mgr.reconcile(vec![Box::new(CountingSub {
1369            id: 42,
1370            counter: counter.clone(),
1371        })]);
1372        thread::sleep(Duration::from_millis(20));
1373        assert_eq!(
1374            counter.load(std::sync::atomic::Ordering::SeqCst),
1375            1,
1376            "second reconcile with same ID must not start another thread"
1377        );
1378
1379        mgr.stop_all();
1380    }
1381
1382    /// CONTRACT: When a subscription is removed via reconcile(), messages it sent
1383    /// before being stopped may still be in the channel. drain_messages() must
1384    /// return these buffered messages.
1385    #[test]
1386    fn contract_buffered_messages_available_after_subscription_stopped() {
1387        let mut mgr = SubscriptionManager::<TestMsg>::new();
1388
1389        struct BurstSub;
1390
1391        impl Subscription<TestMsg> for BurstSub {
1392            fn id(&self) -> SubId {
1393                77
1394            }
1395
1396            fn run(&self, sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1397                // Send a burst of messages immediately
1398                for i in 0..10 {
1399                    let _ = sender.send(TestMsg::Value(i));
1400                }
1401                // Then wait for stop
1402                while !stop.is_stopped() {
1403                    thread::sleep(Duration::from_millis(5));
1404                }
1405            }
1406        }
1407
1408        mgr.reconcile(vec![Box::new(BurstSub)]);
1409        thread::sleep(Duration::from_millis(30));
1410
1411        // Remove the subscription
1412        mgr.reconcile(vec![]);
1413
1414        // Messages sent before stop should still be drainable
1415        let msgs = mgr.drain_messages();
1416        let values: Vec<i32> = msgs
1417            .iter()
1418            .filter_map(|m| match m {
1419                TestMsg::Value(v) => Some(*v),
1420                _ => None,
1421            })
1422            .collect();
1423
1424        assert!(
1425            values.len() >= 5,
1426            "Expected at least 5 buffered messages after stop, got {}",
1427            values.len()
1428        );
1429    }
1430
1431    /// CONTRACT: active_count() must accurately reflect the number of running
1432    /// subscriptions at all times.
1433    #[test]
1434    fn contract_active_count_tracks_running_subscriptions() {
1435        let mut mgr = SubscriptionManager::<TestMsg>::new();
1436
1437        assert_eq!(mgr.active_count(), 0, "empty manager");
1438
1439        mgr.reconcile(vec![
1440            Box::new(Every::with_id(1, Duration::from_millis(50), || {
1441                TestMsg::Tick
1442            })),
1443            Box::new(Every::with_id(2, Duration::from_millis(50), || {
1444                TestMsg::Tick
1445            })),
1446        ]);
1447        assert_eq!(mgr.active_count(), 2, "after starting 2");
1448
1449        mgr.reconcile(vec![Box::new(Every::with_id(
1450            1,
1451            Duration::from_millis(50),
1452            || TestMsg::Tick,
1453        ))]);
1454        assert_eq!(mgr.active_count(), 1, "after removing 1");
1455
1456        mgr.stop_all();
1457        assert_eq!(mgr.active_count(), 0, "after stop_all");
1458    }
1459
1460    /// CONTRACT: The Every subscription ID must be derived from interval only,
1461    /// not from the message factory closure. Two Every subscriptions with the
1462    /// same interval MUST have the same ID regardless of message content.
1463    #[test]
1464    fn contract_every_id_derived_from_interval_only() {
1465        let sub_a = Every::<TestMsg>::new(Duration::from_millis(100), || TestMsg::Tick);
1466        let sub_b = Every::<TestMsg>::new(Duration::from_millis(100), || TestMsg::Value(999));
1467        assert_eq!(
1468            sub_a.id(),
1469            sub_b.id(),
1470            "Every ID must depend only on interval, not message factory"
1471        );
1472
1473        let sub_c = Every::<TestMsg>::new(Duration::from_millis(200), || TestMsg::Tick);
1474        assert_ne!(
1475            sub_a.id(),
1476            sub_c.id(),
1477            "Different intervals must produce different IDs"
1478        );
1479    }
1480
1481    /// CONTRACT: The Every subscription ID formula must remain stable across
1482    /// versions. This captures the exact formula: interval_nanos XOR 0x5449_434B.
1483    #[test]
1484    fn contract_every_id_formula_is_stable() {
1485        let interval = Duration::from_millis(100);
1486        let expected_id = interval.as_nanos() as u64 ^ 0x5449_434B;
1487        let sub = Every::<TestMsg>::new(interval, || TestMsg::Tick);
1488        assert_eq!(
1489            sub.id(),
1490            expected_id,
1491            "Every ID formula must be: interval.as_nanos() as u64 ^ 0x5449_434B"
1492        );
1493    }
1494
1495    /// CONTRACT: Drop on SubscriptionManager must stop all subscriptions.
1496    /// This is the safety net for cleanup even if stop_all() is not called.
1497    #[test]
1498    fn contract_drop_triggers_stop_all() {
1499        use std::sync::atomic::{AtomicUsize, Ordering};
1500
1501        let stop_count = std::sync::Arc::new(AtomicUsize::new(0));
1502
1503        struct StopCountingSub {
1504            id: SubId,
1505            counter: std::sync::Arc<AtomicUsize>,
1506        }
1507
1508        impl Subscription<TestMsg> for StopCountingSub {
1509            fn id(&self) -> SubId {
1510                self.id
1511            }
1512
1513            fn run(&self, _sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1514                while !stop.is_stopped() {
1515                    thread::sleep(Duration::from_millis(5));
1516                }
1517                self.counter.fetch_add(1, Ordering::SeqCst);
1518            }
1519        }
1520
1521        {
1522            let mut mgr = SubscriptionManager::<TestMsg>::new();
1523            mgr.reconcile(vec![
1524                Box::new(StopCountingSub {
1525                    id: 1,
1526                    counter: stop_count.clone(),
1527                }),
1528                Box::new(StopCountingSub {
1529                    id: 2,
1530                    counter: stop_count.clone(),
1531                }),
1532                Box::new(StopCountingSub {
1533                    id: 3,
1534                    counter: stop_count.clone(),
1535                }),
1536            ]);
1537            thread::sleep(Duration::from_millis(20));
1538            // mgr dropped here
1539        }
1540
1541        // Give threads time to notice stop signal and exit
1542        thread::sleep(Duration::from_millis(400));
1543        assert_eq!(
1544            stop_count.load(std::sync::atomic::Ordering::SeqCst),
1545            3,
1546            "all 3 subscription threads must have observed stop signal on drop"
1547        );
1548    }
1549
1550    /// CONTRACT: SUBSCRIPTION_STOP_JOIN_TIMEOUT must be exactly 250ms.
1551    /// The Asupersync migration must preserve this timeout bound.
1552    #[test]
1553    fn contract_stop_join_timeout_is_250ms() {
1554        assert_eq!(
1555            SUBSCRIPTION_STOP_JOIN_TIMEOUT,
1556            Duration::from_millis(250),
1557            "join timeout must be 250ms"
1558        );
1559        assert_eq!(
1560            SUBSCRIPTION_STOP_JOIN_POLL,
1561            Duration::from_millis(1),
1562            "join poll interval must be 1ms (bd-1f2aw)"
1563        );
1564    }
1565
1566    // =========================================================================
1567    // STRUCTURED LIFECYCLE TESTS (bd-1f2aw)
1568    //
1569    // These tests validate the structured cancellation, panic resilience,
1570    // and parallel shutdown improvements.
1571    // =========================================================================
1572
1573    /// bd-1f2aw: A panicking subscription must not crash the runtime.
1574    /// The panic is caught, the panicked flag is set, and telemetry is emitted.
1575    #[test]
1576    fn lifecycle_panic_in_subscription_is_caught() {
1577        use std::sync::atomic::Ordering;
1578
1579        let mut mgr = SubscriptionManager::<TestMsg>::new();
1580
1581        struct PanickingSub;
1582
1583        impl Subscription<TestMsg> for PanickingSub {
1584            fn id(&self) -> SubId {
1585                0xDEAD
1586            }
1587
1588            fn run(&self, _sender: mpsc::Sender<TestMsg>, _stop: StopSignal) {
1589                panic!("intentional test panic in subscription");
1590            }
1591        }
1592
1593        mgr.reconcile(vec![Box::new(PanickingSub)]);
1594
1595        // Give the thread time to panic and be caught.
1596        thread::sleep(Duration::from_millis(50));
1597
1598        // The manager should still be functional.
1599        assert_eq!(
1600            mgr.active_count(),
1601            1,
1602            "panicked sub still tracked as active"
1603        );
1604
1605        // The panicked flag should be set.
1606        assert!(
1607            mgr.active[0].panicked.load(Ordering::Acquire),
1608            "panicked flag should be set after subscription panic"
1609        );
1610
1611        // stop_all should not panic even with a panicked subscription.
1612        mgr.stop_all();
1613        assert_eq!(mgr.active_count(), 0);
1614    }
1615
1616    /// bd-1f2aw: A panicking subscription must not prevent other subscriptions
1617    /// from continuing to deliver messages.
1618    #[test]
1619    fn lifecycle_panic_does_not_affect_sibling_subscriptions() {
1620        let mut mgr = SubscriptionManager::<TestMsg>::new();
1621
1622        struct PanickingSub;
1623        impl Subscription<TestMsg> for PanickingSub {
1624            fn id(&self) -> SubId {
1625                0xBAD
1626            }
1627            fn run(&self, _sender: mpsc::Sender<TestMsg>, _stop: StopSignal) {
1628                panic!("boom");
1629            }
1630        }
1631
1632        mgr.reconcile(vec![
1633            Box::new(PanickingSub),
1634            Box::new(Every::with_id(42, Duration::from_millis(10), || {
1635                TestMsg::Tick
1636            })),
1637        ]);
1638
1639        // Wait for panic to happen and ticks to arrive.
1640        // The tick subscription fires every 10ms; wait long enough for several.
1641        thread::sleep(Duration::from_millis(100));
1642
1643        let msgs = mgr.drain_messages();
1644        assert!(
1645            !msgs.is_empty(),
1646            "sibling subscription should still deliver messages after a panic in another sub"
1647        );
1648
1649        mgr.stop_all();
1650    }
1651
1652    /// bd-1f2aw: Parallel phased shutdown (stop_all) must be faster than
1653    /// sequential shutdown when multiple subscriptions need to wind down.
1654    #[test]
1655    fn lifecycle_stop_all_parallel_shutdown() {
1656        use std::sync::atomic::{AtomicUsize, Ordering};
1657
1658        let stop_count = std::sync::Arc::new(AtomicUsize::new(0));
1659        let sub_count = 4;
1660
1661        struct SlowStopSub {
1662            id: SubId,
1663            counter: std::sync::Arc<AtomicUsize>,
1664        }
1665
1666        impl Subscription<TestMsg> for SlowStopSub {
1667            fn id(&self) -> SubId {
1668                self.id
1669            }
1670
1671            fn run(&self, _sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1672                // Wait for stop, then simulate slow cleanup (50ms).
1673                while !stop.is_stopped() {
1674                    thread::sleep(Duration::from_millis(5));
1675                }
1676                thread::sleep(Duration::from_millis(50));
1677                self.counter.fetch_add(1, Ordering::SeqCst);
1678            }
1679        }
1680
1681        let mut mgr = SubscriptionManager::<TestMsg>::new();
1682        let subs: Vec<Box<dyn Subscription<TestMsg>>> = (0..sub_count)
1683            .map(|i| -> Box<dyn Subscription<TestMsg>> {
1684                Box::new(SlowStopSub {
1685                    id: 1000 + i,
1686                    counter: stop_count.clone(),
1687                })
1688            })
1689            .collect();
1690
1691        mgr.reconcile(subs);
1692        thread::sleep(Duration::from_millis(20));
1693
1694        let start = Instant::now();
1695        mgr.stop_all();
1696        let elapsed = start.elapsed();
1697
1698        // With parallel signal, all 4 subs start their 50ms cleanup
1699        // simultaneously. Sequential would take ~200ms (4 * 50ms).
1700        // Parallel should complete in ~50ms + join overhead.
1701        // Use 150ms as a generous bound (well under 200ms sequential).
1702        assert!(
1703            elapsed < Duration::from_millis(150),
1704            "parallel stop_all took {elapsed:?}, expected < 150ms \
1705             (sequential would be ~{expected_sequential}ms)",
1706            expected_sequential = sub_count * 50
1707        );
1708
1709        // All subscriptions should have completed cleanup.
1710        thread::sleep(Duration::from_millis(20));
1711        assert_eq!(
1712            stop_count.load(Ordering::SeqCst),
1713            sub_count as usize,
1714            "all subscriptions should have completed their cleanup"
1715        );
1716    }
1717
1718    /// bd-1f2aw: Two-phase signal+join in reconcile should allow parallel
1719    /// wind-down when removing multiple subscriptions at once.
1720    #[test]
1721    fn lifecycle_reconcile_removal_uses_parallel_stop() {
1722        use std::sync::atomic::{AtomicUsize, Ordering};
1723
1724        let stop_count = std::sync::Arc::new(AtomicUsize::new(0));
1725
1726        struct SlowStopSub {
1727            id: SubId,
1728            counter: std::sync::Arc<AtomicUsize>,
1729        }
1730
1731        impl Subscription<TestMsg> for SlowStopSub {
1732            fn id(&self) -> SubId {
1733                self.id
1734            }
1735
1736            fn run(&self, _sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1737                while !stop.is_stopped() {
1738                    thread::sleep(Duration::from_millis(5));
1739                }
1740                thread::sleep(Duration::from_millis(40));
1741                self.counter.fetch_add(1, Ordering::SeqCst);
1742            }
1743        }
1744
1745        let mut mgr = SubscriptionManager::<TestMsg>::new();
1746        mgr.reconcile(vec![
1747            Box::new(SlowStopSub {
1748                id: 2000,
1749                counter: stop_count.clone(),
1750            }),
1751            Box::new(SlowStopSub {
1752                id: 2001,
1753                counter: stop_count.clone(),
1754            }),
1755            Box::new(SlowStopSub {
1756                id: 2002,
1757                counter: stop_count.clone(),
1758            }),
1759        ]);
1760        thread::sleep(Duration::from_millis(20));
1761
1762        // Remove all subscriptions via reconcile.
1763        let start = Instant::now();
1764        mgr.reconcile(vec![]);
1765        let elapsed = start.elapsed();
1766
1767        // Parallel: ~40ms + overhead. Sequential would be ~120ms.
1768        assert!(
1769            elapsed < Duration::from_millis(100),
1770            "reconcile removal took {elapsed:?}, expected < 100ms with parallel stop"
1771        );
1772
1773        thread::sleep(Duration::from_millis(20));
1774        assert_eq!(stop_count.load(Ordering::SeqCst), 3);
1775    }
1776
1777    /// bd-1f2aw: has_panicked() must reflect the actual panic state of the thread.
1778    #[test]
1779    fn lifecycle_has_panicked_tracks_state() {
1780        let panicked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1781        let panicked_flag = panicked.clone();
1782
1783        let (signal, trigger) = StopSignal::new();
1784        let thread = thread::spawn(move || {
1785            signal.wait_timeout(Duration::from_secs(10));
1786        });
1787
1788        let running = RunningSubscription {
1789            id: 999,
1790            trigger,
1791            thread: Some(thread),
1792            panicked,
1793        };
1794
1795        assert!(!running.has_panicked(), "should not be panicked initially");
1796
1797        // Simulate a panic flag (normally set by the catch_unwind wrapper).
1798        panicked_flag.store(true, std::sync::atomic::Ordering::Release);
1799        assert!(running.has_panicked(), "should reflect panicked state");
1800
1801        running.stop();
1802    }
1803
1804    /// bd-1f2aw: signal_stop + join_bounded should work correctly as a two-phase
1805    /// shutdown for individual subscriptions.
1806    #[test]
1807    fn lifecycle_signal_then_join_works() {
1808        use std::sync::atomic::{AtomicBool, Ordering};
1809
1810        let completed = std::sync::Arc::new(AtomicBool::new(false));
1811        let completed_clone = completed.clone();
1812
1813        let (signal, trigger) = StopSignal::new();
1814        let thread = thread::spawn(move || {
1815            while !signal.is_stopped() {
1816                thread::sleep(Duration::from_millis(5));
1817            }
1818            completed_clone.store(true, Ordering::SeqCst);
1819        });
1820
1821        let running = RunningSubscription {
1822            id: 888,
1823            trigger,
1824            thread: Some(thread),
1825            panicked: std::sync::Arc::new(AtomicBool::new(false)),
1826        };
1827
1828        running.signal_stop();
1829        let leftover = running.join_bounded();
1830        assert!(
1831            leftover.is_none(),
1832            "cooperative thread should join within timeout"
1833        );
1834        assert!(
1835            completed.load(Ordering::SeqCst),
1836            "thread should have completed"
1837        );
1838    }
1839
1840    /// bd-1f2aw: join_bounded must return the handle for uncooperative threads.
1841    #[test]
1842    fn lifecycle_join_bounded_returns_handle_for_uncooperative() {
1843        use std::sync::atomic::AtomicBool;
1844
1845        let (_signal, trigger) = StopSignal::new();
1846        let thread = thread::spawn(move || {
1847            thread::sleep(Duration::from_millis(500));
1848        });
1849
1850        let running = RunningSubscription {
1851            id: 777,
1852            trigger,
1853            thread: Some(thread),
1854            panicked: std::sync::Arc::new(AtomicBool::new(false)),
1855        };
1856
1857        running.signal_stop();
1858        let start = Instant::now();
1859        let leftover = running.join_bounded();
1860        let elapsed = start.elapsed();
1861
1862        assert!(
1863            leftover.is_some(),
1864            "uncooperative thread should not join within timeout"
1865        );
1866        assert!(
1867            elapsed < Duration::from_millis(400),
1868            "join_bounded should respect the 250ms timeout, took {elapsed:?}"
1869        );
1870    }
1871
1872    /// bd-1f2aw: Restart semantics — a subscription that was stopped via
1873    /// reconcile can be re-started by including it in a subsequent reconcile.
1874    #[test]
1875    fn lifecycle_restart_after_stop() {
1876        let mut mgr = SubscriptionManager::<TestMsg>::new();
1877
1878        // Start subscription.
1879        mgr.reconcile(vec![Box::new(Every::with_id(
1880            300,
1881            Duration::from_millis(10),
1882            || TestMsg::Tick,
1883        ))]);
1884        thread::sleep(Duration::from_millis(30));
1885        let msgs = mgr.drain_messages();
1886        assert!(!msgs.is_empty(), "should receive ticks");
1887
1888        // Remove it.
1889        mgr.reconcile(vec![]);
1890        thread::sleep(Duration::from_millis(20));
1891        let _ = mgr.drain_messages();
1892        thread::sleep(Duration::from_millis(30));
1893        let msgs = mgr.drain_messages();
1894        assert!(msgs.is_empty(), "should stop receiving after removal");
1895
1896        // Restart with same ID.
1897        mgr.reconcile(vec![Box::new(Every::with_id(
1898            300,
1899            Duration::from_millis(10),
1900            || TestMsg::Value(99),
1901        ))]);
1902        thread::sleep(Duration::from_millis(30));
1903        let msgs = mgr.drain_messages();
1904        assert!(
1905            !msgs.is_empty(),
1906            "should receive messages again after restart"
1907        );
1908        assert!(
1909            msgs.iter().any(|m| matches!(m, TestMsg::Value(99))),
1910            "restarted sub should use the new message factory"
1911        );
1912
1913        mgr.stop_all();
1914    }
1915
1916    /// bd-1f2aw: Non-interference contract — subscriptions communicate
1917    /// exclusively through the mpsc channel. They have no access to terminal
1918    /// state, frame buffers, or render surfaces.
1919    ///
1920    /// This test verifies the architectural invariant by demonstrating that
1921    /// subscription threads only interact with the manager through messages,
1922    /// and that the manager's state is consistent after concurrent operations.
1923    #[test]
1924    fn lifecycle_non_interference_with_manager_state() {
1925        use std::sync::atomic::{AtomicUsize, Ordering};
1926
1927        let msg_count = std::sync::Arc::new(AtomicUsize::new(0));
1928
1929        struct CountingSub {
1930            id: SubId,
1931            counter: std::sync::Arc<AtomicUsize>,
1932        }
1933
1934        impl Subscription<TestMsg> for CountingSub {
1935            fn id(&self) -> SubId {
1936                self.id
1937            }
1938
1939            fn run(&self, sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
1940                while !stop.is_stopped() {
1941                    if sender.send(TestMsg::Tick).is_err() {
1942                        break;
1943                    }
1944                    self.counter.fetch_add(1, Ordering::SeqCst);
1945                    thread::sleep(Duration::from_millis(5));
1946                }
1947            }
1948        }
1949
1950        let mut mgr = SubscriptionManager::<TestMsg>::new();
1951
1952        // Start multiple subscriptions.
1953        mgr.reconcile(vec![
1954            Box::new(CountingSub {
1955                id: 400,
1956                counter: msg_count.clone(),
1957            }),
1958            Box::new(CountingSub {
1959                id: 401,
1960                counter: msg_count.clone(),
1961            }),
1962        ]);
1963
1964        thread::sleep(Duration::from_millis(50));
1965
1966        // Manager state is consistent while subscriptions are running.
1967        assert_eq!(mgr.active_count(), 2);
1968        let drained = mgr.drain_messages();
1969        let sent_count = msg_count.load(Ordering::SeqCst);
1970        assert!(sent_count > 0, "subscriptions should have sent messages");
1971        assert!(
1972            drained.len() <= sent_count,
1973            "drained {} but only {} sent",
1974            drained.len(),
1975            sent_count
1976        );
1977
1978        // Stop all — manager state is consistent after shutdown.
1979        mgr.stop_all();
1980        assert_eq!(mgr.active_count(), 0);
1981
1982        // Drain remaining buffered messages.
1983        let remaining = mgr.drain_messages();
1984        let total_drained = drained.len() + remaining.len();
1985        let final_sent = msg_count.load(Ordering::SeqCst);
1986        assert!(
1987            total_drained <= final_sent,
1988            "total drained ({total_drained}) must not exceed total sent ({final_sent})"
1989        );
1990    }
1991
1992    /// bd-1f2aw: Shutdown ordering contract — stop_all() must signal all
1993    /// subscriptions before joining any. Verify by checking that all subs
1994    /// observe the stop signal approximately simultaneously.
1995    #[test]
1996    fn lifecycle_shutdown_signal_ordering() {
1997        use std::sync::atomic::{AtomicU64, Ordering};
1998
1999        let signal_times =
2000            std::sync::Arc::new([AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0)]);
2001        let epoch = Instant::now();
2002
2003        struct TimingStopSub {
2004            id: SubId,
2005            index: usize,
2006            signal_times: std::sync::Arc<[AtomicU64; 3]>,
2007            epoch: Instant,
2008        }
2009
2010        impl Subscription<TestMsg> for TimingStopSub {
2011            fn id(&self) -> SubId {
2012                self.id
2013            }
2014
2015            fn run(&self, _sender: mpsc::Sender<TestMsg>, stop: StopSignal) {
2016                while !stop.is_stopped() {
2017                    thread::sleep(Duration::from_millis(1));
2018                }
2019                let elapsed_us = self.epoch.elapsed().as_micros() as u64;
2020                self.signal_times[self.index].store(elapsed_us, Ordering::SeqCst);
2021            }
2022        }
2023
2024        let mut mgr = SubscriptionManager::<TestMsg>::new();
2025        mgr.reconcile(vec![
2026            Box::new(TimingStopSub {
2027                id: 500,
2028                index: 0,
2029                signal_times: signal_times.clone(),
2030                epoch,
2031            }),
2032            Box::new(TimingStopSub {
2033                id: 501,
2034                index: 1,
2035                signal_times: signal_times.clone(),
2036                epoch,
2037            }),
2038            Box::new(TimingStopSub {
2039                id: 502,
2040                index: 2,
2041                signal_times: signal_times.clone(),
2042                epoch,
2043            }),
2044        ]);
2045        thread::sleep(Duration::from_millis(20));
2046
2047        mgr.stop_all();
2048
2049        // All three should have observed the stop signal at approximately
2050        // the same time (within 10ms of each other), because phase 1 signals
2051        // all before phase 2 joins any.
2052        let t0 = signal_times[0].load(Ordering::SeqCst);
2053        let t1 = signal_times[1].load(Ordering::SeqCst);
2054        let t2 = signal_times[2].load(Ordering::SeqCst);
2055
2056        assert!(
2057            t0 > 0 && t1 > 0 && t2 > 0,
2058            "all subs should have recorded stop time"
2059        );
2060
2061        let max_t = t0.max(t1).max(t2);
2062        let min_t = t0.min(t1).min(t2);
2063        let spread_us = max_t - min_t;
2064
2065        assert!(
2066            spread_us < 10_000, // 10ms
2067            "stop signal spread should be < 10ms for parallel signaling, got {spread_us}us \
2068             (t0={t0}, t1={t1}, t2={t2})"
2069        );
2070    }
2071}