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}