Skip to main content

beam/adapters/
ws_client.rs

1//! Outgoing WebSocket client manager — connects to remote relay servers.
2//!
3//! [`OutgoingWebsocketManager`] is a network adapter that maintains outbound
4//! WebSocket connections to one or more relay servers. It:
5//!
6//! - Connects to each configured URL on startup (with retry)
7//! - Creates a [`WsConn`] actor per connection
8//! - Fans out outgoing messages to all connected clients
9//! - Marks itself as `subscribe_to_everything` so the router sends all
10//!   `Get` and `Put` messages to it (relay behavior)
11//!
12//! # Relay Semantics
13//!
14//! Unlike direct P2P connections, relay servers receive all messages
15//! (not just topic-matched ones). This makes them suitable as bootstrap
16//! nodes for discovering peers and relaying messages when direct
17//! connectivity is unavailable.
18
19use futures_util::StreamExt;
20use std::collections::HashMap;
21use std::sync::Arc;
22use tokio::sync::RwLock;
23use tokio_tungstenite::connect_async;
24
25use crate::Config;
26use crate::actor::{Actor, ActorContext, Addr};
27use crate::adapters::ws_conn::WsConn;
28use crate::message::Message;
29use async_trait::async_trait;
30use log::{debug, info};
31use tokio::time::{Duration, sleep};
32
33/// Manages outbound WebSocket connections to relay servers.
34///
35/// Created with a list of WebSocket URLs. On `pre_start`, connects to each
36/// URL (with retry) and spawns a [`WsConn`] actor per connection. All
37/// outgoing messages are fanned out to all connected clients.
38///
39/// The `clients` map is shared via `Arc<RwLock<...>>` so that e2e tests
40/// can hold their own clone of this manager and observe connection
41/// state via [`Self::connected_count`] while the actor-driven copy
42/// (moved into [`crate::Node`]) performs the actual work.
43#[derive(Clone)]
44pub struct OutgoingWebsocketManager {
45    config: Config,
46    clients: Arc<RwLock<HashMap<String, Addr>>>,
47    urls: Vec<String>,
48}
49
50impl OutgoingWebsocketManager {
51    /// Creates a new manager for the given URLs.
52    ///
53    /// # Arguments
54    ///
55    /// * `config` - Node configuration (uses `allow_public_space` for connections)
56    /// * `urls` - WebSocket URLs to connect to (e.g. `["wss://relay.example.com/ws"]`)
57    pub fn new(config: Config, urls: Vec<String>) -> Self {
58        OutgoingWebsocketManager {
59            urls,
60            clients: Arc::new(RwLock::new(HashMap::new())),
61            config,
62        }
63    }
64
65    /// Returns the number of remote URLs that have an active WebSocket connection.
66    ///
67    /// This is a **readiness signal**: it reflects the state of the
68    /// `clients` map, which is populated only after `connect_async`
69    /// succeeds (see `pre_start`). Once `connected_count() == urls.len()`,
70    /// all configured peer connections have completed the WebSocket
71    /// handshake and are ready to send/receive messages.
72    ///
73    /// e2e tests should poll on this instead of blind `sleep(N)`:
74    ///
75    /// ```ignore
76    /// while client.connected_count().await < expected {
77    ///     tokio::time::sleep(Duration::from_millis(50)).await;
78    /// }
79    /// ```
80    ///
81    /// Returns a snapshot under the read lock; the count is monotonic
82    /// (only grows as connections succeed). Callers do not need to
83    /// handle rollback — the actor never removes entries from `clients`
84    /// during normal operation.
85    pub async fn connected_count(&self) -> usize {
86        self.clients.read().await.len()
87    }
88
89    /// Returns the configured target URLs. Useful for tests that want to
90    /// know how many connections to expect.
91    pub fn urls(&self) -> &[String] {
92        &self.urls
93    }
94}
95
96#[async_trait]
97impl Actor for OutgoingWebsocketManager {
98    async fn pre_start(&mut self, ctx: &ActorContext) {
99        info!("OutgoingWebsocketManager starting");
100        for url in self.urls.iter() {
101            // Retry connection until the websocket is established, or the actor
102            // is shut down. Uses a bounded retry interval so transient DNS or
103            // network blips don't cause permanent disconnection.
104            //
105            // NOTE: The loop condition checks `clients` so that if a prior
106            // iteration's `start_actor` raced ahead of the `insert`, we don't
107            // create a duplicate WsConn for the same URL.
108            loop {
109                if self.clients.read().await.contains_key(url) {
110                    debug!("already connected to {}", url);
111                    break;
112                }
113
114                debug!("attempting WebSocket connect to {}", url);
115                let result = connect_async(url).await;
116
117                if let Ok((socket, _)) = result {
118                    let (sender, receiver) = socket.split();
119                    let client = WsConn::new(sender, receiver, self.config.allow_public_space);
120                    let addr = ctx.start_actor(Box::new(client));
121                    self.clients.write().await.insert(url.clone(), addr);
122                    debug!("connected to {}", url);
123                    break;
124                }
125
126                debug!("connect to {} failed, retrying in 200ms", url);
127                sleep(Duration::from_millis(200)).await;
128            }
129        }
130    }
131
132    /// Returns `true` — this adapter subscribes to all messages (relay behavior).
133    fn subscribe_to_everything(&self) -> bool {
134        true
135    }
136
137    async fn handle(&mut self, message: Message, _ctx: &ActorContext) {
138        // Fan out to all connected clients.
139        //
140        // Snapshot under the read lock so we don't hold the lock while
141        // calling `send` (which may briefly contend on the actor's
142        // mailbox). `send().is_err()` clients are skipped — `pre_start`
143        // will retry the connection on the next loop iteration. We do
144        // not evict dead clients from the map here; that is the
145        // responsibility of the reconnection loop, which already runs
146        // periodically. This keeps `handle` simple and avoids
147        // priority inversion under load.
148        let snapshot: Vec<Addr> = self.clients.read().await.values().cloned().collect();
149        for client in snapshot {
150            let _ = client.send(message.clone());
151        }
152    }
153
154    async fn stopping(&mut self, _ctx: &ActorContext) {
155        let count = self.clients.read().await.len();
156        info!(
157            "OutgoingWebsocketManager stopping — {} outgoing connections",
158            count
159        );
160        // The WsConn child actors receive stop signals via ActorContext::stop()
161        // and send WebSocket Close frames in their own stopping(). Here we
162        // clear the map so no further fan-out attempts are made.
163        self.clients.write().await.clear();
164    }
165}