Skip to main content

rust_web_server/ws_proxy/
mod.rs

1//! WebSocket reverse proxy.
2//!
3//! [`WsProxy`] listens for incoming TCP connections, reads the initial HTTP
4//! request, verifies it is a WebSocket upgrade, connects to a backend, performs
5//! the WebSocket handshake end-to-end, and then bidirectionally tunnels raw
6//! WebSocket bytes between the client and the backend.
7//!
8//! Plain (`ws://`) backends use two threads (one per direction) via
9//! `std::io::copy`, identical to the original implementation.
10//!
11//! TLS (`wss://`) backends use a single-thread polling loop: both streams are
12//! set to a 5 ms read timeout and the loop alternates between the two
13//! directions, sleeping 1 ms when neither side has data.  This avoids the
14//! deadlock that arises when trying to share a `rustls::StreamOwned` between
15//! two blocking threads.
16//!
17//! # Health checks
18//!
19//! `WsProxy::new` treats every configured backend as always live — matching
20//! the original behavior, with no background monitoring. The config-driven
21//! proxy's `[ws_proxy.health_check]` (see `spec/PROXY_SERVER_CONFIG.md`) opts
22//! a `[[ws_proxy]]` block into the same background health checker used for
23//! `[[upstream]]` pools (`proxy_config::health::start_health_checker`), which
24//! periodically probes each backend with a plain HTTP `GET` and removes it
25//! from rotation after enough consecutive failures. If every backend is
26//! currently unhealthy, new WebSocket upgrade attempts get `503 Service
27//! Unavailable` instead of being routed to a backend known to be down.
28//!
29//! # Example
30//!
31//! ```rust,no_run
32//! use rust_web_server::ws_proxy::WsProxy;
33//!
34//! // Plain WebSocket — two backends, round-robin.
35//! WsProxy::new(["ws://chat-backend:9000", "ws://chat-backend:9001"])
36//!     .connect_timeout_ms(3000)
37//!     .bind("0.0.0.0:8080")
38//!     .unwrap();
39//!
40//! // TLS WebSocket (requires http-client or http2 feature).
41//! WsProxy::new(["wss://chat-backend.internal:443"])
42//!     .bind("0.0.0.0:8080")
43//!     .unwrap();
44//! ```
45
46use std::io::{Read, Write};
47use std::net::{TcpListener, TcpStream, ToSocketAddrs};
48use std::sync::{
49    Arc, RwLock,
50    atomic::{AtomicUsize, Ordering},
51};
52use std::time::Duration;
53
54use crate::request::Request;
55use crate::websocket::WebSocket;
56
57/// WebSocket reverse proxy with round-robin load balancing.
58///
59/// Accepts HTTP/1.1 WebSocket upgrade requests and tunnels traffic to one of the
60/// configured backends.
61///
62/// Backend URL schemes:
63/// - `"host:port"` — plain TCP (no scheme)
64/// - `"ws://host:port"` — plain TCP (port defaults to 80)
65/// - `"wss://host:port"` — TLS (port defaults to 443); requires the
66///   `http-client` or `http2` Cargo feature
67///
68/// Call [`WsProxy::bind`] to start. It blocks the calling thread indefinitely.
69pub struct WsProxy {
70    /// Every backend this proxy was configured with, validated at
71    /// construction — used only to reject an empty configuration in `bind()`.
72    all_backends: Vec<String>,
73    /// Backends currently considered live. Round-robin picks only from this
74    /// list. Defaults to a clone of `all_backends` (no health check attached);
75    /// [`WsProxy::with_live_backends`] lets a caller (the config-driven
76    /// proxy's builder) share this list with a background health checker
77    /// that updates it over time.
78    live: Arc<RwLock<Vec<String>>>,
79    counter: Arc<AtomicUsize>,
80    connect_timeout: Duration,
81    read_timeout: Duration,
82}
83
84impl WsProxy {
85    /// Create a proxy that distributes connections across `backends` in
86    /// round-robin order. Every backend is treated as always live — no
87    /// health check is attached; use the config-driven proxy's
88    /// `[ws_proxy.health_check]` for that.
89    ///
90    /// Each entry may be `"host:port"`, `"ws://host:port"`, or
91    /// `"wss://host:port"`.  `wss://` requires the `http-client` or `http2`
92    /// Cargo feature.
93    pub fn new<I, S>(backends: I) -> Self
94    where
95        I: IntoIterator<Item = S>,
96        S: Into<String>,
97    {
98        let all_backends: Vec<String> = backends
99            .into_iter()
100            .map(|b| b.into())
101            .filter(|b| WsBackend::parse(b).is_some())
102            .collect();
103        let live = Arc::new(RwLock::new(all_backends.clone()));
104        WsProxy {
105            all_backends,
106            live,
107            counter: Arc::new(AtomicUsize::new(0)),
108            connect_timeout: Duration::from_secs(5),
109            read_timeout: Duration::from_secs(30),
110        }
111    }
112
113    /// Build a proxy whose live-backend list is externally managed —
114    /// `live` is shared with (and expected to be updated by) a health
115    /// checker you run yourself, on whatever schedule and probe logic you
116    /// choose. `all_backends` is the full configured list, used only for
117    /// `bind()`'s empty-configuration check; round-robin only ever picks
118    /// from `live`.
119    ///
120    /// This is what the config-driven proxy's `[ws_proxy.health_check]`
121    /// uses internally (see `proxy_config::health::start_health_checker`),
122    /// exposed directly for library users who want WS backend health
123    /// checking without the config file — e.g. a custom probe that performs
124    /// a real WebSocket handshake rather than a plain HTTP `GET`.
125    ///
126    /// ```rust,no_run
127    /// use std::sync::{Arc, RwLock};
128    /// use rust_web_server::ws_proxy::WsProxy;
129    ///
130    /// let all = vec!["ws://chat-a:9000".to_string(), "ws://chat-b:9000".to_string()];
131    /// let live = Arc::new(RwLock::new(all.clone()));
132    ///
133    /// // Run your own probe loop on another thread, writing into `live`:
134    /// let checker_live = Arc::clone(&live);
135    /// std::thread::spawn(move || loop {
136    ///     std::thread::sleep(std::time::Duration::from_secs(10));
137    ///     // *checker_live.write().unwrap() = probe_and_filter(&all);
138    ///     let _ = &checker_live;
139    /// });
140    ///
141    /// WsProxy::with_live_backends(all, live)
142    ///     .bind("0.0.0.0:8080")
143    ///     .expect("WS proxy failed");
144    /// ```
145    pub fn with_live_backends(all_backends: Vec<String>, live: Arc<RwLock<Vec<String>>>) -> Self {
146        WsProxy {
147            all_backends,
148            live,
149            counter: Arc::new(AtomicUsize::new(0)),
150            connect_timeout: Duration::from_secs(5),
151            read_timeout: Duration::from_secs(30),
152        }
153    }
154
155    /// Override the TCP connect timeout to each backend (default: 5 s).
156    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
157        self.connect_timeout = Duration::from_millis(ms);
158        self
159    }
160
161    /// Override the idle read timeout on client connections (default: 30 s).
162    ///
163    /// For `wss://` backends this controls the outer idle timeout on the
164    /// client side; the internal polling interval is fixed at 5 ms.
165    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
166        self.read_timeout = Duration::from_millis(ms);
167        self
168    }
169
170    /// Bind on `addr` and start proxying WebSocket connections. Blocks indefinitely.
171    pub fn bind(self, addr: &str) -> Result<(), String> {
172        if self.all_backends.is_empty() {
173            return Err("WsProxy: no backends configured".to_string());
174        }
175        let listener = TcpListener::bind(addr)
176            .map_err(|e| format!("WsProxy: bind on {} failed: {}", addr, e))?;
177        println!("WsProxy: listening on {}", addr);
178        let proxy = Arc::new(self);
179        for incoming in listener.incoming() {
180            let client = match incoming {
181                Ok(s) => s,
182                Err(e) => {
183                    eprintln!("WsProxy: accept error: {}", e);
184                    continue;
185                }
186            };
187            let p = Arc::clone(&proxy);
188            std::thread::spawn(move || {
189                if let Err(e) = p.handle(client) {
190                    eprintln!("WsProxy: {}", e);
191                }
192            });
193        }
194        Ok(())
195    }
196
197    /// Pick the next backend in round-robin order from the current live
198    /// list. Returns `None` if the live list is empty — every backend is
199    /// currently marked unhealthy (or, for `WsProxy::new`, every configured
200    /// backend failed to parse).
201    fn pick_backend(&self) -> Option<WsBackend> {
202        let live = self.live.read().unwrap_or_else(|e| e.into_inner());
203        if live.is_empty() {
204            return None;
205        }
206        let i = self.counter.fetch_add(1, Ordering::Relaxed) % live.len();
207        WsBackend::parse(&live[i])
208    }
209
210    fn handle(&self, mut client: TcpStream) -> Result<(), String> {
211        client.set_read_timeout(Some(self.read_timeout)).ok();
212
213        // Read the initial HTTP request.
214        let mut buf = vec![0u8; 8192];
215        let n = client.read(&mut buf).map_err(|e| e.to_string())?;
216        if n == 0 {
217            return Ok(());
218        }
219
220        let request = Request::parse(&buf[..n])
221            .map_err(|e| format!("WsProxy: invalid HTTP request: {}", e))?;
222
223        if !WebSocket::is_upgrade_request(&request) {
224            let _ = client.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
225            return Err(format!(
226                "WsProxy: not a WebSocket upgrade — method={}, uri={}",
227                request.method, request.request_uri
228            ));
229        }
230
231        let backend = match self.pick_backend() {
232            Some(b) => b,
233            None => {
234                let _ = client.write_all(
235                    b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
236                );
237                return Err("WsProxy: no healthy backend available".to_string());
238            }
239        };
240        let addr_str = &backend.addr;
241        let sock_addr = addr_str
242            .to_socket_addrs()
243            .map_err(|e| format!("WsProxy: DNS lookup for {} failed: {}", addr_str, e))?
244            .next()
245            .ok_or_else(|| format!("WsProxy: no address for {}", addr_str))?;
246
247        let tcp = TcpStream::connect_timeout(&sock_addr, self.connect_timeout)
248            .map_err(|e| format!("WsProxy: connect to {} failed: {}", addr_str, e))?;
249
250        // Use backend.host (no port) for the Host header and TLS SNI.
251        let upgrade_req = build_upgrade_request(&request, &backend.host);
252
253        if backend.tls {
254            self.handle_tls(client, tcp, &request, &backend.host, upgrade_req, addr_str)
255        } else {
256            handle_plain(client, tcp, &request, upgrade_req, addr_str)
257        }
258    }
259
260    fn handle_tls(
261        &self,
262        mut client: TcpStream,
263        tcp: TcpStream,
264        request: &Request,
265        host: &str,
266        upgrade_req: Vec<u8>,
267        addr_str: &str,
268    ) -> Result<(), String> {
269        #[cfg(any(feature = "http-client", feature = "http2"))]
270        {
271            use rustls::pki_types::ServerName;
272            use std::sync::Arc;
273
274            let root_store = rustls::RootCertStore::from_iter(
275                webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
276            );
277            let config = Arc::new(
278                crate::tls_client_builder()
279                    .with_root_certificates(root_store)
280                    .with_no_client_auth(),
281            );
282            let server_name = ServerName::try_from(host)
283                .map_err(|e| format!("WsProxy: invalid hostname '{}': {}", host, e))?
284                .to_owned();
285            let conn = rustls::ClientConnection::new(config, server_name)
286                .map_err(|e| format!("WsProxy: TLS init failed: {}", e))?;
287            let mut tls = rustls::StreamOwned::new(conn, tcp);
288
289            // Send WebSocket upgrade request over TLS.
290            tls.write_all(&upgrade_req)
291                .map_err(|e| format!("WsProxy: write upgrade to {} failed: {}", addr_str, e))?;
292
293            // Read backend's 101 Switching Protocols.
294            let mut resp_buf = vec![0u8; 4096];
295            let m = tls
296                .read(&mut resp_buf)
297                .map_err(|e| format!("WsProxy: read 101 from {} failed: {}", addr_str, e))?;
298            let preview = &resp_buf[..m.min(20)];
299            if !preview.starts_with(b"HTTP/1.1 101") && !preview.starts_with(b"HTTP/1.0 101") {
300                return Err(format!(
301                    "WsProxy: backend {} did not send 101 (got {:?})",
302                    addr_str,
303                    std::str::from_utf8(&resp_buf[..m.min(80)]).unwrap_or("?")
304                ));
305            }
306
307            // Forward 101 to client.
308            let response_101 = WebSocket::handshake_response(request)?;
309            let raw_101 = format_response_head(&response_101);
310            client
311                .write_all(&raw_101)
312                .map_err(|e| format!("WsProxy: write 101 to client failed: {}", e))?;
313
314            // Bidirectional relay via single-thread poll loop.
315            // Set both sides to 5 ms polling timeout.
316            tls.sock.set_read_timeout(Some(Duration::from_millis(5))).ok();
317            client.set_read_timeout(Some(Duration::from_millis(5))).ok();
318            relay_tls(client, tls);
319            Ok(())
320        }
321
322        #[cfg(not(any(feature = "http-client", feature = "http2")))]
323        {
324            let _ = (tcp, request, host, upgrade_req, addr_str);
325            let _ = client.write_all(
326                b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n",
327            );
328            Err("WsProxy: wss:// upstreams require the http-client or http2 Cargo feature".to_string())
329        }
330    }
331}
332
333/// Relay over a plain TCP backend using two blocking threads.
334fn handle_plain(
335    mut client: TcpStream,
336    mut backend: TcpStream,
337    request: &Request,
338    upgrade_req: Vec<u8>,
339    addr_str: &str,
340) -> Result<(), String> {
341    backend
342        .write_all(&upgrade_req)
343        .map_err(|e| format!("WsProxy: write upgrade to {} failed: {}", addr_str, e))?;
344
345    let mut resp_buf = vec![0u8; 4096];
346    let m = backend
347        .read(&mut resp_buf)
348        .map_err(|e| format!("WsProxy: read 101 from {} failed: {}", addr_str, e))?;
349    let preview = &resp_buf[..m.min(20)];
350    if !preview.starts_with(b"HTTP/1.1 101") && !preview.starts_with(b"HTTP/1.0 101") {
351        return Err(format!(
352            "WsProxy: backend {} did not send 101 (got {:?})",
353            addr_str,
354            std::str::from_utf8(&resp_buf[..m.min(80)]).unwrap_or("?")
355        ));
356    }
357
358    let response_101 = WebSocket::handshake_response(request)?;
359    let raw_101 = format_response_head(&response_101);
360    client
361        .write_all(&raw_101)
362        .map_err(|e| format!("WsProxy: write 101 to client failed: {}", e))?;
363
364    // Bidirectional tunnel — one thread per direction.
365    let mut client_r = client.try_clone().map_err(|e| e.to_string())?;
366    let mut backend_r = backend.try_clone().map_err(|e| e.to_string())?;
367    let mut client_w = client;
368    let mut backend_w = backend;
369
370    let t1 = std::thread::spawn(move || {
371        std::io::copy(&mut client_r, &mut backend_w).ok();
372        let _ = backend_w.shutdown(std::net::Shutdown::Write);
373    });
374    let t2 = std::thread::spawn(move || {
375        std::io::copy(&mut backend_r, &mut client_w).ok();
376        let _ = client_w.shutdown(std::net::Shutdown::Write);
377    });
378
379    let _ = t1.join();
380    let _ = t2.join();
381    Ok(())
382}
383
384/// Bidirectional relay between `client` (plain TCP) and a TLS backend.
385///
386/// Uses a single-thread polling loop to avoid the deadlock that arises when
387/// sharing a `rustls::StreamOwned` between two blocking threads (the reader
388/// thread would hold the TLS lock while waiting for data, blocking the writer).
389///
390/// Both streams are set to a 5 ms read timeout before this function is called.
391/// The loop reads from each side in turn; when neither has data it sleeps 1 ms.
392#[cfg(any(feature = "http-client", feature = "http2"))]
393fn relay_tls(
394    mut client: TcpStream,
395    mut backend: rustls::StreamOwned<rustls::ClientConnection, TcpStream>,
396) {
397    use std::io::ErrorKind::{TimedOut, WouldBlock};
398    let mut buf = [0u8; 8192];
399
400    loop {
401        let mut active = false;
402
403        // client → TLS backend
404        let cn = match client.read(&mut buf) {
405            Ok(0) => break, // client closed
406            Ok(n) => n,
407            Err(ref e) if e.kind() == TimedOut || e.kind() == WouldBlock => 0,
408            Err(_) => break,
409        };
410        if cn > 0 {
411            if backend.write_all(&buf[..cn]).is_err() {
412                break;
413            }
414            active = true;
415        }
416
417        // TLS backend → client
418        let bn = match backend.read(&mut buf) {
419            Ok(0) => break, // backend closed
420            Ok(n) => n,
421            Err(ref e) if e.kind() == TimedOut || e.kind() == WouldBlock => 0,
422            Err(_) => break,
423        };
424        if bn > 0 {
425            if client.write_all(&buf[..bn]).is_err() {
426                break;
427            }
428            active = true;
429        }
430
431        if !active {
432            std::thread::sleep(Duration::from_millis(1));
433        }
434    }
435}
436
437// ── helpers ───────────────────────────────────────────────────────────────────
438
439fn build_upgrade_request(request: &Request, backend_host: &str) -> Vec<u8> {
440    let mut req = format!(
441        "{} {} HTTP/1.1\r\nHost: {}\r\n",
442        request.method, request.request_uri, backend_host
443    );
444    for header in &request.headers {
445        if header.name.to_lowercase() == "host" {
446            continue;
447        }
448        req.push_str(&format!("{}: {}\r\n", header.name, header.value));
449    }
450    req.push_str("\r\n");
451    req.into_bytes()
452}
453
454fn format_response_head(response: &crate::response::Response) -> Vec<u8> {
455    let mut out = format!(
456        "HTTP/1.1 {} {}\r\n",
457        response.status_code, response.reason_phrase
458    )
459    .into_bytes();
460    for h in &response.headers {
461        out.extend_from_slice(h.name.as_bytes());
462        out.extend_from_slice(b": ");
463        out.extend_from_slice(h.value.as_bytes());
464        out.extend_from_slice(b"\r\n");
465    }
466    out.extend_from_slice(b"\r\n");
467    out
468}
469
470// ── Backend URL parsing ───────────────────────────────────────────────────────
471
472struct WsBackend {
473    /// `"host:port"` — passed to `to_socket_addrs()` for TCP connect.
474    addr: String,
475    /// Bare hostname (no port) — used for the `Host` header and TLS SNI.
476    host: String,
477    /// `true` when the URL scheme was `wss://`.
478    tls: bool,
479}
480
481impl WsBackend {
482    fn parse(url: &str) -> Option<Self> {
483        let (rest, tls, default_port) = if let Some(r) = url.strip_prefix("wss://") {
484            (r, true, 443u16)
485        } else if let Some(r) = url.strip_prefix("ws://") {
486            (r, false, 80u16)
487        } else {
488            (url, false, 80u16)
489        };
490
491        // Drop any path component.
492        let host_port = rest.split('/').next().unwrap_or(rest);
493
494        let (host, port) = if let Some(colon) = host_port.rfind(':') {
495            let port_str = &host_port[colon + 1..];
496            if let Ok(p) = port_str.parse::<u16>() {
497                (host_port[..colon].to_string(), p)
498            } else {
499                (host_port.to_string(), default_port)
500            }
501        } else {
502            (host_port.to_string(), default_port)
503        };
504
505        if host.is_empty() {
506            return None;
507        }
508
509        Some(WsBackend {
510            addr: format!("{}:{}", host, port),
511            host,
512            tls,
513        })
514    }
515}
516
517// ── WsBackend::parse unit tests ───────────────────────────────────────────────
518
519#[cfg(test)]
520mod backend_parse_tests {
521    use super::WsBackend;
522
523    fn parse(url: &str) -> Option<(String, String, bool)> {
524        WsBackend::parse(url).map(|b| (b.addr, b.host, b.tls))
525    }
526
527    #[test]
528    fn bare_host_port() {
529        assert_eq!(
530            Some(("chat:9000".into(), "chat".into(), false)),
531            parse("chat:9000")
532        );
533    }
534
535    #[test]
536    fn ws_scheme_plain() {
537        assert_eq!(
538            Some(("backend:3000".into(), "backend".into(), false)),
539            parse("ws://backend:3000")
540        );
541    }
542
543    #[test]
544    fn ws_scheme_default_port() {
545        assert_eq!(
546            Some(("api.example.com:80".into(), "api.example.com".into(), false)),
547            parse("ws://api.example.com")
548        );
549    }
550
551    #[test]
552    fn wss_scheme_sets_tls() {
553        assert_eq!(
554            Some(("secure.example.com:443".into(), "secure.example.com".into(), true)),
555            parse("wss://secure.example.com")
556        );
557    }
558
559    #[test]
560    fn wss_scheme_explicit_port() {
561        assert_eq!(
562            Some(("secure.example.com:8443".into(), "secure.example.com".into(), true)),
563            parse("wss://secure.example.com:8443")
564        );
565    }
566
567    #[test]
568    fn wss_default_port_is_443() {
569        let b = WsBackend::parse("wss://api.example.com").unwrap();
570        assert_eq!("api.example.com:443", b.addr);
571        assert_eq!("api.example.com", b.host);
572        assert!(b.tls);
573    }
574
575    #[test]
576    fn ws_default_port_is_80() {
577        let b = WsBackend::parse("ws://api.example.com").unwrap();
578        assert_eq!("api.example.com:80", b.addr);
579        assert!(!b.tls);
580    }
581
582    #[test]
583    fn empty_host_returns_none() {
584        assert_eq!(None, parse("wss://"));
585    }
586
587    #[test]
588    fn bare_host_no_port_defaults_to_80() {
589        assert_eq!(
590            Some(("myhost:80".into(), "myhost".into(), false)),
591            parse("myhost")
592        );
593    }
594
595    #[test]
596    fn path_component_is_ignored() {
597        // URL paths after host:port are stripped — only host:port matters.
598        let b = WsBackend::parse("ws://backend:9000/ws").unwrap();
599        assert_eq!("backend:9000", b.addr);
600        assert_eq!("backend", b.host);
601    }
602}
603
604// ── Live-backend-list (health check) tests ────────────────────────────────────
605
606#[cfg(test)]
607mod live_backends_tests {
608    use super::WsProxy;
609    use std::sync::{Arc, RwLock};
610
611    #[test]
612    fn new_picks_backends_in_round_robin_order() {
613        let proxy = WsProxy::new(["ws://a:1", "ws://b:1", "ws://c:1"]);
614        let picked: Vec<String> = (0..6)
615            .map(|_| proxy.pick_backend().unwrap().addr)
616            .collect();
617        assert_eq!(
618            vec!["a:1", "b:1", "c:1", "a:1", "b:1", "c:1"],
619            picked
620        );
621    }
622
623    #[test]
624    fn new_filters_out_unparseable_backends() {
625        // "wss://" alone has no host and WsBackend::parse rejects it.
626        let proxy = WsProxy::new(["ws://good:1", "wss://"]);
627        assert_eq!(vec!["ws://good:1".to_string()], proxy.all_backends);
628    }
629
630    #[test]
631    fn pick_backend_returns_none_when_live_list_is_empty() {
632        let proxy = WsProxy::with_live_backends(
633            vec!["ws://a:1".to_string()],
634            Arc::new(RwLock::new(vec![])),
635        );
636        assert!(proxy.pick_backend().is_none(), "no live backends means no pick");
637    }
638
639    #[test]
640    fn pick_backend_reflects_live_list_updates() {
641        // Simulates what proxy_config::health::start_health_checker does:
642        // mutate the shared live list out from under a running proxy.
643        let live = Arc::new(RwLock::new(vec!["ws://a:1".to_string(), "ws://b:1".to_string()]));
644        let proxy = WsProxy::with_live_backends(
645            vec!["ws://a:1".to_string(), "ws://b:1".to_string()],
646            Arc::clone(&live),
647        );
648
649        assert!(proxy.pick_backend().is_some());
650
651        // "a" fails its health check and is removed from rotation.
652        *live.write().unwrap() = vec!["ws://b:1".to_string()];
653        for _ in 0..4 {
654            assert_eq!("b:1", proxy.pick_backend().unwrap().addr);
655        }
656
657        // All backends fail — no live backend left.
658        live.write().unwrap().clear();
659        assert!(proxy.pick_backend().is_none());
660
661        // "a" recovers.
662        *live.write().unwrap() = vec!["ws://a:1".to_string()];
663        assert_eq!("a:1", proxy.pick_backend().unwrap().addr);
664    }
665}