Skip to main content

ig_client/application/
dynamic_streamer.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 30/10/25
5******************************************************************************/
6
7//! Dynamic market streaming with thread-safe subscription management.
8//!
9//! This module provides a wrapper around `StreamerClient` that allows dynamic
10//! addition and removal of market subscriptions from multiple threads.
11
12use crate::application::client::StreamerClient;
13use crate::error::AppError;
14use crate::model::streaming::StreamingMarketField;
15use crate::presentation::price::PriceData;
16use std::collections::HashSet;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::Duration;
20use tokio::sync::{Notify, RwLock, mpsc};
21use tracing::{debug, info, warn};
22
23/// How long [`DynamicMarketStreamer::reconnect`] waits for the previous
24/// connection to confirm it has closed before starting the next one.
25///
26/// This is a safety bound on a handshake, not a guess at how long teardown
27/// takes: the normal path completes as soon as the old connection task signals,
28/// and exceeding this only produces a warning.
29const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
30
31/// Dynamic market streamer with thread-safe subscription management.
32///
33/// This struct wraps a `StreamerClient` and provides methods to dynamically
34/// add, remove, and clear market subscriptions. All operations are thread-safe
35/// and can be called from multiple threads concurrently.
36///
37/// # Examples
38///
39/// ```ignore
40/// use ig_client::application::dynamic_streamer::DynamicMarketStreamer;
41/// use ig_client::model::streaming::StreamingMarketField;
42/// use std::collections::HashSet;
43///
44/// #[tokio::main]
45/// async fn main() -> Result<(), ig_client::error::AppError> {
46///     // Create fields to subscribe to
47///     let fields = HashSet::from([
48///         StreamingMarketField::Bid,
49///         StreamingMarketField::Offer,
50///     ]);
51///
52///     // Create the dynamic streamer
53///     let mut streamer = DynamicMarketStreamer::new(fields);
54///
55///     // Get the receiver for price updates
56///     let mut receiver = streamer.get_receiver().await?;
57///
58///     // Add markets from different threads
59///     let streamer_clone = streamer.clone();
60///     tokio::spawn(async move {
61///         if let Err(e) = streamer_clone.add("IX.D.DAX.DAILY.IP".to_string()).await {
62///             tracing::error!("Failed to add market: {}", e);
63///         }
64///     });
65///
66///     // Start receiving updates
67///     tokio::spawn(async move {
68///         while let Some(price_data) = receiver.recv().await {
69///             println!("Price update: {}", price_data);
70///         }
71///     });
72///
73///     // Connect and run
74///     streamer.connect(None).await?;
75///     Ok(())
76/// }
77/// ```
78pub struct DynamicMarketStreamer {
79    /// Set of EPICs currently subscribed
80    epics: Arc<RwLock<HashSet<String>>>,
81    /// Market fields to subscribe to
82    fields: HashSet<StreamingMarketField>,
83    /// Channel sender for price updates
84    price_tx: Arc<RwLock<Option<mpsc::UnboundedSender<PriceData>>>>,
85    /// Channel receiver for price updates (taken on first get_receiver call)
86    price_rx: Arc<RwLock<Option<mpsc::UnboundedReceiver<PriceData>>>>,
87    /// Flag indicating if the streamer is connected
88    is_connected: Arc<RwLock<bool>>,
89    /// Shutdown signal for current connection
90    shutdown_signal: Arc<RwLock<Option<Arc<Notify>>>>,
91    /// Completion handshake for the current connection task: notified once the
92    /// task has closed its Lightstreamer session and is about to exit. This is
93    /// what lets [`reconnect`](Self::reconnect) wait for the old connection
94    /// instead of sleeping for a guessed interval.
95    stopped_signal: Arc<RwLock<Option<Arc<Notify>>>>,
96    /// Monotonic connection generation. Bumped on every `start_internal`; a
97    /// connection task only clears `is_connected` on exit if its captured
98    /// generation is still current, so a superseded task tearing down its old
99    /// connection cannot clobber the state of the newer one that replaced it.
100    generation: Arc<AtomicU64>,
101}
102
103impl DynamicMarketStreamer {
104    /// Creates a new dynamic market streamer.
105    ///
106    /// Construction only wires up in-memory channels and state, so it neither
107    /// awaits nor fails; establishing the network connection happens later in
108    /// [`start`](Self::start).
109    ///
110    /// # Arguments
111    ///
112    /// * `fields` - Set of market data fields to receive (e.g., BID, OFFER, etc.)
113    ///
114    /// # Returns
115    ///
116    /// A new `DynamicMarketStreamer` instance.
117    ///
118    /// # Examples
119    ///
120    /// ```ignore
121    /// let fields = HashSet::from([
122    ///     StreamingMarketField::Bid,
123    ///     StreamingMarketField::Offer,
124    /// ]);
125    /// let streamer = DynamicMarketStreamer::new(fields);
126    /// ```
127    #[must_use]
128    pub fn new(fields: HashSet<StreamingMarketField>) -> Self {
129        let (price_tx, price_rx) = mpsc::unbounded_channel();
130
131        Self {
132            epics: Arc::new(RwLock::new(HashSet::new())),
133            fields,
134            price_tx: Arc::new(RwLock::new(Some(price_tx))),
135            price_rx: Arc::new(RwLock::new(Some(price_rx))),
136            is_connected: Arc::new(RwLock::new(false)),
137            shutdown_signal: Arc::new(RwLock::new(None)),
138            stopped_signal: Arc::new(RwLock::new(None)),
139            generation: Arc::new(AtomicU64::new(0)),
140        }
141    }
142
143    /// Adds a market EPIC to the subscription list.
144    ///
145    /// If the streamer is already connected, this will reconnect with the updated list.
146    ///
147    /// # Arguments
148    ///
149    /// * `epic` - The market EPIC to subscribe to
150    ///
151    /// # Returns
152    ///
153    /// Returns `Ok(())` if the EPIC was added successfully.
154    ///
155    /// # Examples
156    ///
157    /// ```ignore
158    /// streamer.add("IX.D.DAX.DAILY.IP".to_string()).await?;
159    /// ```
160    pub async fn add(&self, epic: String) -> Result<(), AppError> {
161        let mut epics = self.epics.write().await;
162
163        if epics.contains(&epic) {
164            debug!("EPIC {} already subscribed", epic);
165            return Ok(());
166        }
167
168        epics.insert(epic.clone());
169        info!("Added EPIC {} to subscription list", epic);
170        drop(epics); // Release lock
171
172        // If already connected, reconnect with new list
173        let is_connected = *self.is_connected.read().await;
174        if is_connected {
175            self.reconnect().await?;
176        }
177
178        Ok(())
179    }
180
181    /// Removes a market EPIC from the subscription list.
182    ///
183    /// If the streamer is already connected, this will reconnect with the updated list.
184    ///
185    /// # Arguments
186    ///
187    /// * `epic` - The market EPIC to remove
188    ///
189    /// # Returns
190    ///
191    /// Returns `Ok(())` if the EPIC was removed successfully.
192    ///
193    /// # Examples
194    ///
195    /// ```ignore
196    /// streamer.remove("IX.D.DAX.DAILY.IP".to_string()).await?;
197    /// ```
198    pub async fn remove(&self, epic: String) -> Result<(), AppError> {
199        let mut epics = self.epics.write().await;
200
201        let was_removed = epics.remove(&epic);
202        if was_removed {
203            info!("Removed EPIC {} from subscription list", epic);
204        } else {
205            debug!("EPIC {} was not in subscription list", epic);
206        }
207        drop(epics); // Release lock
208
209        // If already connected and something was removed, reconnect
210        if was_removed {
211            let is_connected = *self.is_connected.read().await;
212            if is_connected {
213                self.reconnect().await?;
214            }
215        }
216
217        Ok(())
218    }
219
220    /// Clears all market EPICs from the subscription list.
221    ///
222    /// If the streamer is connected, the live connection is shut down so it
223    /// stops forwarding updates for the cleared EPICs, mirroring [`remove`]:
224    /// the current connection is signalled and, because reconnection no-ops on
225    /// an empty EPIC set, no new connection is started. After this call the
226    /// streamer reports as disconnected.
227    ///
228    /// [`remove`]: DynamicMarketStreamer::remove
229    ///
230    /// # Returns
231    ///
232    /// Returns `Ok(())` when all EPICs have been cleared and, if it was
233    /// connected, the connection has been signalled to stop.
234    ///
235    /// # Examples
236    ///
237    /// ```ignore
238    /// streamer.clear().await?;
239    /// ```
240    pub async fn clear(&self) -> Result<(), AppError> {
241        let mut epics = self.epics.write().await;
242        let count = epics.len();
243        epics.clear();
244        info!("Cleared {} EPICs from subscription list", count);
245        drop(epics); // Release lock before touching connection state
246
247        // If connected, shut the live connection down so data flow actually
248        // stops. `reconnect` signals the current connection; `start_internal`
249        // no-ops on the now-empty EPIC set, so nothing reconnects.
250        let is_connected = *self.is_connected.read().await;
251        if is_connected {
252            self.reconnect().await?;
253            // No new connection is started for an empty EPIC set, so make the
254            // stopped state explicit and immediate instead of waiting for the
255            // connection task to flip it asynchronously.
256            *self.is_connected.write().await = false;
257        }
258
259        Ok(())
260    }
261
262    /// Gets the current list of subscribed EPICs.
263    ///
264    /// # Returns
265    ///
266    /// Returns a vector containing all currently subscribed EPICs.
267    ///
268    /// # Examples
269    ///
270    /// ```ignore
271    /// let epics = streamer.get_epics().await;
272    /// println!("Subscribed to {} markets", epics.len());
273    /// ```
274    pub async fn get_epics(&self) -> Vec<String> {
275        let epics = self.epics.read().await;
276        epics.iter().cloned().collect()
277    }
278
279    /// Gets the receiver for price updates.
280    ///
281    /// This method can only be called once. Subsequent calls will return an error.
282    ///
283    /// # Returns
284    ///
285    /// Returns a receiver channel for `PriceData` updates, or an error if the
286    /// receiver has already been taken.
287    ///
288    /// # Examples
289    ///
290    /// ```ignore
291    /// let mut receiver = streamer.get_receiver().await?;
292    /// tokio::spawn(async move {
293    ///     while let Some(price_data) = receiver.recv().await {
294    ///         println!("Price update: {}", price_data);
295    ///     }
296    /// });
297    /// ```
298    pub async fn get_receiver(&self) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
299        let mut rx_lock = self.price_rx.write().await;
300        rx_lock
301            .take()
302            .ok_or_else(|| AppError::InvalidInput("Receiver already taken".to_string()))
303    }
304
305    /// Reconnects the streamer with the current list of EPICs.
306    ///
307    /// This method disconnects the current client and creates a new one with
308    /// the updated EPIC list.
309    ///
310    /// The old connection is signalled and then *waited for*: its task reports
311    /// completion through the `stopped` handshake, so the new session is only
312    /// opened once the old one has closed. There is no timed sleep here — the
313    /// wait is bounded by [`SHUTDOWN_GRACE`] purely so a wedged teardown cannot
314    /// stall the caller forever.
315    async fn reconnect(&self) -> Result<(), AppError> {
316        info!("Reconnecting with updated EPIC list...");
317
318        // Signal shutdown to the current connection and take its completion
319        // handshake. Both guards are scoped: nothing is held across the await.
320        let stopped = {
321            let shutdown_lock = self.shutdown_signal.read().await;
322            if let Some(signal) = shutdown_lock.as_ref() {
323                signal.notify_one();
324            }
325            let stopped_lock = self.stopped_signal.read().await;
326            stopped_lock.as_ref().map(Arc::clone)
327        };
328
329        if let Some(stopped) = stopped
330            && tokio::time::timeout(SHUTDOWN_GRACE, stopped.notified())
331                .await
332                .is_err()
333        {
334            warn!(
335                grace_ms = SHUTDOWN_GRACE.as_millis(),
336                "previous streaming connection did not confirm shutdown in time; continuing"
337            );
338        }
339
340        // Start new connection
341        let epics = self.get_epics().await;
342        if !epics.is_empty() {
343            self.start_internal().await?;
344        }
345
346        Ok(())
347    }
348
349    /// Internal method to start connection.
350    async fn start_internal(&self) -> Result<(), AppError> {
351        let epics = self.get_epics().await;
352
353        if epics.is_empty() {
354            warn!("No EPICs to subscribe to");
355            return Ok(());
356        }
357
358        info!("Starting connection with {} EPICs", epics.len());
359
360        // Claim a new connection generation. The task spawned below owns this
361        // number; a later `start_internal` bumps it, marking any earlier task as
362        // superseded so it will not clobber `is_connected` on teardown.
363        let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
364
365        // Create new client
366        let mut new_client = StreamerClient::new().await?;
367
368        // Subscribe to all EPICs
369        let fields = self.fields.clone();
370        let mut receiver = new_client.market_subscribe(epics.clone(), fields).await?;
371
372        // Forward updates to the main channel. The task ends on its own when
373        // the connection closes (which closes `receiver`) or when the consumer
374        // drops the shared receiver, so it needs no separate shutdown path.
375        let forwarder = {
376            let price_tx = self.price_tx.read().await;
377            price_tx.as_ref().map(|tx| {
378                let tx = tx.clone();
379                tokio::spawn(async move {
380                    while let Some(price_data) = receiver.recv().await {
381                        if tx.send(price_data).is_err() {
382                            warn!("Failed to send price update: receiver dropped");
383                            break;
384                        }
385                    }
386                    debug!("Subscription forwarding task ended");
387                })
388            })
389        };
390
391        // Create new shutdown signal and its completion handshake
392        let signal = Arc::new(Notify::new());
393        let stopped = Arc::new(Notify::new());
394        *self.shutdown_signal.write().await = Some(Arc::clone(&signal));
395        *self.stopped_signal.write().await = Some(Arc::clone(&stopped));
396
397        // Mark as connected
398        *self.is_connected.write().await = true;
399
400        // Spawn connection task in background
401        let is_connected = Arc::clone(&self.is_connected);
402        let generation = Arc::clone(&self.generation);
403
404        // Move the freshly-built client (and its matching shutdown signal) INTO
405        // the task by value. Handing off through the shared `self.client` slot
406        // would race: a second `start_internal` could overwrite the slot before
407        // this task takes it, so the task would connect the wrong client while
408        // parked on this task's (now-stale) signal. Owning the client here binds
409        // each task to exactly the client and signal it was created with, and
410        // the connection still runs without holding any lock.
411        tokio::spawn(async move {
412            let mut c = new_client;
413            let result = {
414                let r = c.connect(Some(signal)).await;
415                // The connection has ended (shutdown signal or error): close the
416                // Lightstreamer session and drain its converter tasks, then drop
417                // the owned client so it is never left half-open.
418                if let Err(e) = c.disconnect().await {
419                    tracing::error!("Error closing streamer session: {}", e);
420                }
421                r
422            };
423
424            // Mark as disconnected only if we are still the current generation.
425            // A newer `start_internal` (from reconnect on add/remove/clear) may
426            // have already brought up a fresh connection while this superseded
427            // task was still tearing its old one down; clobbering the flag here
428            // would leave the streamer reporting disconnected while live.
429            if generation.load(Ordering::SeqCst) == my_generation {
430                *is_connected.write().await = false;
431            }
432
433            match result {
434                Ok(()) => info!("Connection task completed successfully"),
435                Err(e) => tracing::error!("Connection task failed: {:?}", e),
436            }
437
438            // The forwarder's upstream channel is closed by the teardown above,
439            // so it is already finishing; awaiting it keeps the handshake
440            // honest, i.e. nothing from this connection is still in flight when
441            // `reconnect` proceeds.
442            if let Some(forwarder) = forwarder
443                && let Err(e) = forwarder.await
444            {
445                warn!(error = %e, "price forwarding task did not exit cleanly");
446            }
447
448            // Tell a waiting `reconnect` that this connection is fully closed.
449            stopped.notify_one();
450        });
451
452        info!("Connection task started in background");
453        Ok(())
454    }
455
456    /// Starts the connection to the Lightstreamer server and subscribes to all initial EPICs.
457    ///
458    /// This method subscribes to all EPICs in the subscription list and then spawns a background
459    /// task to maintain the connection. This allows dynamic subscription management while connected.
460    ///
461    /// # Returns
462    ///
463    /// Returns `Ok(())` immediately after starting the connection task.
464    ///
465    /// # Examples
466    ///
467    /// ```ignore
468    /// // Start connection
469    /// streamer.start().await?;
470    ///
471    /// // Keep main thread alive
472    /// tokio::signal::ctrl_c().await?;
473    /// ```
474    pub async fn start(&mut self) -> Result<(), AppError> {
475        self.start_internal().await
476    }
477
478    /// Connects to the Lightstreamer server and blocks until shutdown.
479    ///
480    /// This is a convenience method that calls `start()` and then waits for a
481    /// shutdown signal. Use `start()` if you need non-blocking behavior.
482    ///
483    /// Signal handling lives here rather than in `lightstreamer-rs`, which
484    /// deliberately installs none: interpreting `SIGINT` is an application's
485    /// decision, not a protocol client's.
486    ///
487    /// # Returns
488    ///
489    /// Returns `Ok(())` when the connection is closed gracefully.
490    ///
491    /// # Errors
492    ///
493    /// Returns [`AppError`] if the connection could not be started, or if the
494    /// Ctrl-C handler could not be installed — a streamer that cannot be
495    /// stopped must not be reported as running.
496    ///
497    /// # Examples
498    ///
499    /// ```ignore
500    /// // Connect and block until shutdown
501    /// streamer.connect().await?;
502    /// ```
503    pub async fn connect(&mut self) -> Result<(), AppError> {
504        self.start().await?;
505
506        let interrupted = tokio::signal::ctrl_c().await;
507
508        // Disconnect either way: the streamer must not be left running because
509        // the signal machinery failed.
510        self.disconnect().await?;
511
512        interrupted
513            .map_err(|e| AppError::Generic(format!("could not wait for the interrupt signal: {e}")))
514    }
515
516    /// Disconnects from the Lightstreamer server.
517    ///
518    /// This method gracefully closes the connection to the server.
519    ///
520    /// # Returns
521    ///
522    /// Returns `Ok(())` if the disconnection was successful.
523    ///
524    /// # Examples
525    ///
526    /// ```ignore
527    /// streamer.disconnect().await?;
528    /// ```
529    pub async fn disconnect(&mut self) -> Result<(), AppError> {
530        // Signal shutdown to the current connection task. The task owns its
531        // `StreamerClient` by value, so once its `connect` returns (woken by this
532        // signal's stored permit even if it has not started awaiting yet) it
533        // closes the Lightstreamer session itself. The guard is scoped so it is
534        // never held across an `.await`.
535        {
536            let shutdown_lock = self.shutdown_signal.read().await;
537            if let Some(signal) = shutdown_lock.as_ref() {
538                signal.notify_one();
539            }
540        }
541
542        *self.is_connected.write().await = false;
543        info!("Disconnected from Lightstreamer server");
544        Ok(())
545    }
546}
547
548impl Clone for DynamicMarketStreamer {
549    fn clone(&self) -> Self {
550        Self {
551            epics: Arc::clone(&self.epics),
552            fields: self.fields.clone(),
553            price_tx: Arc::clone(&self.price_tx),
554            price_rx: Arc::clone(&self.price_rx),
555            is_connected: Arc::clone(&self.is_connected),
556            shutdown_signal: Arc::clone(&self.shutdown_signal),
557            stopped_signal: Arc::clone(&self.stopped_signal),
558            generation: Arc::clone(&self.generation),
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::DynamicMarketStreamer;
566    use std::collections::HashSet;
567    use std::sync::Arc;
568    use std::sync::atomic::Ordering;
569    use std::time::Duration;
570    use tokio::sync::Notify;
571
572    const TEST_EPIC: &str = "IX.D.DAX.DAILY.IP";
573    const OTHER_EPIC: &str = "IX.D.FTSE.DAILY.IP";
574
575    // --- EPIC-set mutation while disconnected (no network required) --------
576
577    #[tokio::test]
578    async fn test_add_inserts_epic_when_not_connected() {
579        let streamer = DynamicMarketStreamer::new(HashSet::new());
580
581        let result = streamer.add(TEST_EPIC.to_string()).await;
582
583        assert!(result.is_ok(), "add should succeed: {result:?}");
584        assert_eq!(
585            streamer.get_epics().await,
586            vec![TEST_EPIC.to_string()],
587            "add must insert the EPIC into the subscription set"
588        );
589    }
590
591    #[tokio::test]
592    async fn test_add_is_idempotent_for_duplicate_epic() {
593        let streamer = DynamicMarketStreamer::new(HashSet::new());
594
595        for _ in 0..3 {
596            let result = streamer.add(TEST_EPIC.to_string()).await;
597            assert!(result.is_ok(), "repeated add should succeed: {result:?}");
598        }
599
600        assert_eq!(
601            streamer.get_epics().await.len(),
602            1,
603            "adding the same EPIC repeatedly must not create duplicates"
604        );
605    }
606
607    #[tokio::test]
608    async fn test_remove_absent_epic_is_noop() {
609        let streamer = DynamicMarketStreamer::new(HashSet::new());
610        streamer.epics.write().await.insert(TEST_EPIC.to_string());
611
612        let result = streamer.remove(OTHER_EPIC.to_string()).await;
613
614        assert!(
615            result.is_ok(),
616            "removing an absent EPIC should succeed: {result:?}"
617        );
618        assert_eq!(
619            streamer.get_epics().await,
620            vec![TEST_EPIC.to_string()],
621            "removing an absent EPIC must leave the set unchanged"
622        );
623    }
624
625    #[tokio::test]
626    async fn test_remove_existing_epic_when_not_connected_empties_set() {
627        let streamer = DynamicMarketStreamer::new(HashSet::new());
628        streamer.epics.write().await.insert(TEST_EPIC.to_string());
629
630        let result = streamer.remove(TEST_EPIC.to_string()).await;
631
632        assert!(result.is_ok(), "remove should succeed: {result:?}");
633        assert!(
634            streamer.get_epics().await.is_empty(),
635            "removing the only EPIC must empty the set"
636        );
637    }
638
639    // --- get_receiver() is single-take -------------------------------------
640
641    #[tokio::test]
642    async fn test_get_receiver_can_only_be_taken_once() {
643        let streamer = DynamicMarketStreamer::new(HashSet::new());
644
645        let first = streamer.get_receiver().await;
646        assert!(
647            first.is_ok(),
648            "first get_receiver should hand out the receiver: {first:?}"
649        );
650
651        let second = streamer.get_receiver().await;
652        assert!(
653            second.is_err(),
654            "second get_receiver must fail once the receiver has been taken"
655        );
656    }
657
658    // --- is_connected transitions & disconnect shutdown handshake ----------
659
660    #[tokio::test]
661    async fn test_is_connected_transitions_on_disconnect() {
662        let mut streamer = DynamicMarketStreamer::new(HashSet::new());
663
664        // A freshly constructed streamer starts disconnected.
665        assert!(
666            !*streamer.is_connected.read().await,
667            "a new streamer must start disconnected"
668        );
669
670        // Simulate the connected state that `start_internal` establishes on a
671        // successful connection (offline: we set the same internal fields a live
672        // connection would).
673        *streamer.is_connected.write().await = true;
674        let signal = Arc::new(Notify::new());
675        *streamer.shutdown_signal.write().await = Some(Arc::clone(&signal));
676        assert!(
677            *streamer.is_connected.read().await,
678            "streamer should report connected once a connection is live"
679        );
680
681        // Disconnecting transitions it back.
682        let result = streamer.disconnect().await;
683        assert!(result.is_ok(), "disconnect should succeed: {result:?}");
684        assert!(
685            !*streamer.is_connected.read().await,
686            "disconnect must transition the streamer back to disconnected"
687        );
688    }
689
690    #[tokio::test]
691    async fn test_disconnect_signals_shutdown_and_marks_disconnected() {
692        let mut streamer = DynamicMarketStreamer::new(HashSet::new());
693
694        // Simulate a live connection with a parked shutdown waiter.
695        *streamer.is_connected.write().await = true;
696        let signal = Arc::new(Notify::new());
697        *streamer.shutdown_signal.write().await = Some(Arc::clone(&signal));
698        let waiter = tokio::spawn(async move { signal.notified().await });
699
700        let result = streamer.disconnect().await;
701
702        assert!(result.is_ok(), "disconnect should succeed: {result:?}");
703        assert!(
704            !*streamer.is_connected.read().await,
705            "disconnect must mark the streamer disconnected"
706        );
707        // With a stored permit the waiter wakes deterministically.
708        assert!(
709            tokio::time::timeout(Duration::from_secs(1), waiter)
710                .await
711                .is_ok(),
712            "the parked connection did not observe the shutdown signal from disconnect()"
713        );
714    }
715
716    #[tokio::test]
717    async fn test_remove_last_epic_while_connected_signals_reconnect() {
718        let streamer = DynamicMarketStreamer::new(HashSet::new());
719
720        // Simulate a live connection subscribed to a single EPIC, with a
721        // shutdown signal a connection task would be parked on.
722        streamer.epics.write().await.insert(TEST_EPIC.to_string());
723        *streamer.is_connected.write().await = true;
724        let signal = Arc::new(Notify::new());
725        *streamer.shutdown_signal.write().await = Some(Arc::clone(&signal));
726
727        // Stand-in for the live connection waiting to be told to reconnect.
728        let waiter = tokio::spawn(async move { signal.notified().await });
729
730        // Removing the last EPIC while connected is an EPIC change: it must
731        // signal the current connection. Because the resulting EPIC set is
732        // empty, `reconnect` no-ops on the restart and never touches the
733        // network, keeping this test fully offline.
734        let result = streamer.remove(TEST_EPIC.to_string()).await;
735
736        assert!(result.is_ok(), "remove should succeed: {result:?}");
737        assert!(
738            streamer.get_epics().await.is_empty(),
739            "removing the last EPIC must empty the subscription set"
740        );
741        // The old connection's signal must have fired. With a stored permit the
742        // waiter wakes deterministically.
743        assert!(
744            tokio::time::timeout(Duration::from_secs(1), waiter)
745                .await
746                .is_ok(),
747            "the live connection did not observe the reconnect signal from remove()"
748        );
749    }
750
751    // --- Task 4: clear() must stop data flow when connected ----------------
752
753    #[tokio::test]
754    async fn test_clear_when_not_connected_empties_epics() {
755        let streamer = DynamicMarketStreamer::new(HashSet::new());
756        streamer.epics.write().await.insert(TEST_EPIC.to_string());
757
758        let result = streamer.clear().await;
759
760        assert!(result.is_ok(), "clear should succeed: {result:?}");
761        assert!(
762            streamer.get_epics().await.is_empty(),
763            "EPIC set should be empty after clear"
764        );
765        assert!(
766            !*streamer.is_connected.read().await,
767            "should not report connected when it never was"
768        );
769    }
770
771    #[tokio::test]
772    async fn test_clear_when_connected_signals_shutdown_and_reports_stopped() {
773        let streamer = DynamicMarketStreamer::new(HashSet::new());
774
775        // Simulate a live connection: one EPIC, connected, and a shutdown
776        // signal that a connection task would be parked on.
777        streamer.epics.write().await.insert(TEST_EPIC.to_string());
778        *streamer.is_connected.write().await = true;
779        let signal = Arc::new(Notify::new());
780        *streamer.shutdown_signal.write().await = Some(Arc::clone(&signal));
781
782        // Stand-in for the live connection waiting to be shut down.
783        let waiter = tokio::spawn(async move { signal.notified().await });
784
785        let result = streamer.clear().await;
786
787        assert!(result.is_ok(), "clear should succeed: {result:?}");
788        assert!(
789            streamer.get_epics().await.is_empty(),
790            "EPIC set should be empty after clear"
791        );
792        assert!(
793            !*streamer.is_connected.read().await,
794            "clear must mark the streamer disconnected so data flow stops"
795        );
796        // clear() must have signalled the live connection to shut down. With a
797        // stored permit the waiter wakes deterministically.
798        assert!(
799            tokio::time::timeout(Duration::from_secs(1), waiter)
800                .await
801                .is_ok(),
802            "live connection did not observe the shutdown signal from clear()"
803        );
804    }
805
806    #[tokio::test]
807    async fn test_superseded_generation_does_not_clear_is_connected() {
808        // Models the connection-task teardown race: a newer generation is live
809        // (is_connected == true) while an older, superseded task finishes tearing
810        // its connection down. The superseded task must NOT clear the flag.
811        let streamer = DynamicMarketStreamer::new(HashSet::new());
812
813        // A newer connection has come up: bump the generation and mark connected.
814        let newer = streamer.generation.fetch_add(1, Ordering::SeqCst) + 1;
815        *streamer.is_connected.write().await = true;
816
817        // An older task captured an earlier generation.
818        let older = newer - 1;
819
820        // Replicate the task's exit guard for the superseded (older) generation.
821        if streamer.generation.load(Ordering::SeqCst) == older {
822            *streamer.is_connected.write().await = false;
823        }
824        assert!(
825            *streamer.is_connected.read().await,
826            "a superseded generation must not clear is_connected on the newer one"
827        );
828
829        // The current generation's own teardown still clears it.
830        if streamer.generation.load(Ordering::SeqCst) == newer {
831            *streamer.is_connected.write().await = false;
832        }
833        assert!(
834            !*streamer.is_connected.read().await,
835            "the current generation's teardown must clear is_connected"
836        );
837    }
838}