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 crate::utils::FxHashMap;
20use http::Uri;
21use std::sync::Arc;
22use tokio::sync::RwLock;
23use tokio_websockets::ClientBuilder;
24
25use crate::Config;
26use crate::actor::{Actor, ActorContext, Addr};
27use crate::adapters::ws_conn::WsConn;
28use crate::message::Message;
29use crate::tokio_time::sleep;
30use async_trait::async_trait;
31use log::{debug, info};
32use web_time::Duration;
33
34/// Manages outbound WebSocket connections to relay servers.
35///
36/// Created with a list of WebSocket URLs. On `pre_start`, connects to each
37/// URL (with retry) and spawns a [`WsConn`] actor per connection. All
38/// outgoing messages are fanned out to all connected clients.
39///
40/// The `clients` map is shared via `Arc<RwLock<...>>` so that e2e tests
41/// can hold their own clone of this manager and observe connection
42/// state via [`Self::connected_count`] while the actor-driven copy
43/// (moved into [`crate::Node`]) performs the actual work.
44#[derive(Clone)]
45pub struct OutgoingWebsocketManager {
46    config: Config,
47    clients: Arc<RwLock<FxHashMap<String, Addr>>>,
48    urls: Vec<String>,
49}
50
51impl OutgoingWebsocketManager {
52    /// Creates a new manager for the given URLs.
53    ///
54    /// # Arguments
55    ///
56    /// * `config` - Node configuration (uses `allow_public_space` for connections)
57    /// * `urls` - WebSocket URLs to connect to (e.g. `["wss://relay.example.com/ws"]`)
58    pub fn new(config: Config, urls: Vec<String>) -> Self {
59        OutgoingWebsocketManager {
60            urls,
61            clients: Arc::new(RwLock::new(FxHashMap::default())),
62            config,
63        }
64    }
65
66    /// Returns the number of remote URLs that have an active WebSocket connection.
67    ///
68    /// This is a **readiness signal**: it reflects the state of the
69    /// `clients` map, which is populated only after `connect_async`
70    /// succeeds (see `pre_start`). Once `connected_count() == urls.len()`,
71    /// all configured peer connections have completed the WebSocket
72    /// handshake and are ready to send/receive messages.
73    ///
74    /// e2e tests should poll on this instead of blind `sleep(N)`:
75    ///
76    /// ```ignore
77    /// while client.connected_count().await < expected {
78    ///     crate::tokio_time::sleep(Duration::from_millis(50)).await;
79    /// }
80    /// ```
81    ///
82    /// Returns a snapshot under the read lock; the count is monotonic
83    /// (only grows as connections succeed). Callers do not need to
84    /// handle rollback — the actor never removes entries from `clients`
85    /// during normal operation.
86    pub async fn connected_count(&self) -> usize {
87        self.clients.read().await.len()
88    }
89
90    /// Returns the configured target URLs. Useful for tests that want to
91    /// know how many connections to expect.
92    pub fn urls(&self) -> &[String] {
93        &self.urls
94    }
95}
96
97#[async_trait]
98impl Actor for OutgoingWebsocketManager {
99    async fn pre_start(&mut self, ctx: &ActorContext) {
100        info!("OutgoingWebsocketManager starting");
101        for url in self.urls.iter() {
102            // Retry connection until the websocket is established, or the actor
103            // is shut down. Uses a bounded retry interval so transient DNS or
104            // network blips don't cause permanent disconnection.
105            //
106            // NOTE: The loop condition checks `clients` so that if a prior
107            // iteration's `start_actor` raced ahead of the `insert`, we don't
108            // create a duplicate WsConn for the same URL.
109            loop {
110                if self.clients.read().await.contains_key(url) {
111                    debug!("already connected to {}", url);
112                    break;
113                }
114
115                debug!("attempting WebSocket connect to {}", url);
116                let uri = match url.parse::<Uri>() {
117                    Ok(u) => u,
118                    Err(_) => {
119                        debug!("invalid URL: {}", url);
120                        sleep(Duration::from_millis(200)).await;
121                        continue;
122                    }
123                };
124
125                // Resolve and connect TCP ourselves (async, non-blocking).
126                // tokio-websockets' default resolver uses blocking getaddrinfo
127                // which deadlocks current_thread runtimes (e.g. #[tokio::test]).
128                let host = uri.host().unwrap_or("127.0.0.1");
129                let port = uri
130                    .port_u16()
131                    .unwrap_or(if uri.scheme_str() == Some("wss") {
132                        443
133                    } else {
134                        80
135                    });
136                let tcp = tokio::net::TcpStream::connect((host, port)).await;
137
138                let result = match tcp {
139                    Ok(stream) => {
140                        // See ws_server.rs for rationale on flush_threshold.
141                        let ws_config =
142                            tokio_websockets::Config::default().flush_threshold(usize::MAX);
143                        ClientBuilder::from_uri(uri)
144                            .config(ws_config)
145                            .connect_on(stream)
146                            .await
147                    }
148                    Err(e) => {
149                        debug!("TCP connect to {}:{} failed: {}", host, port, e);
150                        sleep(Duration::from_millis(200)).await;
151                        continue;
152                    }
153                };
154
155                if let Ok((socket, _)) = result {
156                    let client = WsConn::new(socket, self.config.allow_public_space);
157                    let addr = ctx.start_actor(Box::new(client));
158                    self.clients.write().await.insert(url.clone(), addr);
159                    debug!("connected to {}", url);
160                    break;
161                }
162
163                debug!("connect to {} failed, retrying in 200ms", url);
164                sleep(Duration::from_millis(200)).await;
165            }
166        }
167    }
168
169    /// Returns `true` — this adapter subscribes to all messages (relay behavior).
170    fn subscribe_to_everything(&self) -> bool {
171        true
172    }
173
174    async fn handle(&mut self, message: Arc<Message>, _ctx: &ActorContext) {
175        // Fan out to all connected clients.
176        //
177        // Snapshot under the read lock so we don't hold the lock while
178        // calling `send` (which may briefly contend on the actor's
179        // mailbox). `send().is_err()` clients are skipped — `pre_start`
180        // will retry the connection on the next loop iteration. We do
181        // not evict dead clients from the map here; that is the
182        // responsibility of the reconnection loop, which already runs
183        // periodically. This keeps `handle` simple and avoids
184        // priority inversion under load.
185        let snapshot: Vec<Addr> = self.clients.read().await.values().cloned().collect();
186        for client in snapshot {
187            let _ = client.send(Arc::clone(&message));
188        }
189    }
190
191    async fn stopping(&mut self, _ctx: &ActorContext) {
192        let count = self.clients.read().await.len();
193        info!(
194            "OutgoingWebsocketManager stopping — {} outgoing connections",
195            count
196        );
197        // The WsConn child actors receive stop signals via ActorContext::stop()
198        // and send WebSocket Close frames in their own stopping(). Here we
199        // clear the map so no further fan-out attempts are made.
200        self.clients.write().await.clear();
201    }
202}