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