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}