Skip to main content

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