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}