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}