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 rustls::ClientConfig;
273            use std::sync::Arc;
274
275            let root_store = rustls::RootCertStore::from_iter(
276                webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
277            );
278            let config = Arc::new(
279                ClientConfig::builder()
280                    .with_root_certificates(root_store)
281                    .with_no_client_auth(),
282            );
283            let server_name = ServerName::try_from(host)
284                .map_err(|e| format!("WsProxy: invalid hostname '{}': {}", host, e))?
285                .to_owned();
286            let conn = rustls::ClientConnection::new(config, server_name)
287                .map_err(|e| format!("WsProxy: TLS init failed: {}", e))?;
288            let mut tls = rustls::StreamOwned::new(conn, tcp);
289
290            // Send WebSocket upgrade request over TLS.
291            tls.write_all(&upgrade_req)
292                .map_err(|e| format!("WsProxy: write upgrade to {} failed: {}", addr_str, e))?;
293
294            // Read backend's 101 Switching Protocols.
295            let mut resp_buf = vec![0u8; 4096];
296            let m = tls
297                .read(&mut resp_buf)
298                .map_err(|e| format!("WsProxy: read 101 from {} failed: {}", addr_str, e))?;
299            let preview = &resp_buf[..m.min(20)];
300            if !preview.starts_with(b"HTTP/1.1 101") && !preview.starts_with(b"HTTP/1.0 101") {
301                return Err(format!(
302                    "WsProxy: backend {} did not send 101 (got {:?})",
303                    addr_str,
304                    std::str::from_utf8(&resp_buf[..m.min(80)]).unwrap_or("?")
305                ));
306            }
307
308            // Forward 101 to client.
309            let response_101 = WebSocket::handshake_response(request)?;
310            let raw_101 = format_response_head(&response_101);
311            client
312                .write_all(&raw_101)
313                .map_err(|e| format!("WsProxy: write 101 to client failed: {}", e))?;
314
315            // Bidirectional relay via single-thread poll loop.
316            // Set both sides to 5 ms polling timeout.
317            tls.sock.set_read_timeout(Some(Duration::from_millis(5))).ok();
318            client.set_read_timeout(Some(Duration::from_millis(5))).ok();
319            relay_tls(client, tls);
320            Ok(())
321        }
322
323        #[cfg(not(any(feature = "http-client", feature = "http2")))]
324        {
325            let _ = (tcp, request, host, upgrade_req, addr_str);
326            let _ = client.write_all(
327                b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n",
328            );
329            Err("WsProxy: wss:// upstreams require the http-client or http2 Cargo feature".to_string())
330        }
331    }
332}
333
334/// Relay over a plain TCP backend using two blocking threads.
335fn handle_plain(
336    mut client: TcpStream,
337    mut backend: TcpStream,
338    request: &Request,
339    upgrade_req: Vec<u8>,
340    addr_str: &str,
341) -> Result<(), String> {
342    backend
343        .write_all(&upgrade_req)
344        .map_err(|e| format!("WsProxy: write upgrade to {} failed: {}", addr_str, e))?;
345
346    let mut resp_buf = vec![0u8; 4096];
347    let m = backend
348        .read(&mut resp_buf)
349        .map_err(|e| format!("WsProxy: read 101 from {} failed: {}", addr_str, e))?;
350    let preview = &resp_buf[..m.min(20)];
351    if !preview.starts_with(b"HTTP/1.1 101") && !preview.starts_with(b"HTTP/1.0 101") {
352        return Err(format!(
353            "WsProxy: backend {} did not send 101 (got {:?})",
354            addr_str,
355            std::str::from_utf8(&resp_buf[..m.min(80)]).unwrap_or("?")
356        ));
357    }
358
359    let response_101 = WebSocket::handshake_response(request)?;
360    let raw_101 = format_response_head(&response_101);
361    client
362        .write_all(&raw_101)
363        .map_err(|e| format!("WsProxy: write 101 to client failed: {}", e))?;
364
365    // Bidirectional tunnel — one thread per direction.
366    let mut client_r = client.try_clone().map_err(|e| e.to_string())?;
367    let mut backend_r = backend.try_clone().map_err(|e| e.to_string())?;
368    let mut client_w = client;
369    let mut backend_w = backend;
370
371    let t1 = std::thread::spawn(move || {
372        std::io::copy(&mut client_r, &mut backend_w).ok();
373        let _ = backend_w.shutdown(std::net::Shutdown::Write);
374    });
375    let t2 = std::thread::spawn(move || {
376        std::io::copy(&mut backend_r, &mut client_w).ok();
377        let _ = client_w.shutdown(std::net::Shutdown::Write);
378    });
379
380    let _ = t1.join();
381    let _ = t2.join();
382    Ok(())
383}
384
385/// Bidirectional relay between `client` (plain TCP) and a TLS backend.
386///
387/// Uses a single-thread polling loop to avoid the deadlock that arises when
388/// sharing a `rustls::StreamOwned` between two blocking threads (the reader
389/// thread would hold the TLS lock while waiting for data, blocking the writer).
390///
391/// Both streams are set to a 5 ms read timeout before this function is called.
392/// The loop reads from each side in turn; when neither has data it sleeps 1 ms.
393#[cfg(any(feature = "http-client", feature = "http2"))]
394fn relay_tls(
395    mut client: TcpStream,
396    mut backend: rustls::StreamOwned<rustls::ClientConnection, TcpStream>,
397) {
398    use std::io::ErrorKind::{TimedOut, WouldBlock};
399    let mut buf = [0u8; 8192];
400
401    loop {
402        let mut active = false;
403
404        // client → TLS backend
405        let cn = match client.read(&mut buf) {
406            Ok(0) => break, // client closed
407            Ok(n) => n,
408            Err(ref e) if e.kind() == TimedOut || e.kind() == WouldBlock => 0,
409            Err(_) => break,
410        };
411        if cn > 0 {
412            if backend.write_all(&buf[..cn]).is_err() {
413                break;
414            }
415            active = true;
416        }
417
418        // TLS backend → client
419        let bn = match backend.read(&mut buf) {
420            Ok(0) => break, // backend closed
421            Ok(n) => n,
422            Err(ref e) if e.kind() == TimedOut || e.kind() == WouldBlock => 0,
423            Err(_) => break,
424        };
425        if bn > 0 {
426            if client.write_all(&buf[..bn]).is_err() {
427                break;
428            }
429            active = true;
430        }
431
432        if !active {
433            std::thread::sleep(Duration::from_millis(1));
434        }
435    }
436}
437
438// ── helpers ───────────────────────────────────────────────────────────────────
439
440fn build_upgrade_request(request: &Request, backend_host: &str) -> Vec<u8> {
441    let mut req = format!(
442        "{} {} HTTP/1.1\r\nHost: {}\r\n",
443        request.method, request.request_uri, backend_host
444    );
445    for header in &request.headers {
446        if header.name.to_lowercase() == "host" {
447            continue;
448        }
449        req.push_str(&format!("{}: {}\r\n", header.name, header.value));
450    }
451    req.push_str("\r\n");
452    req.into_bytes()
453}
454
455fn format_response_head(response: &crate::response::Response) -> Vec<u8> {
456    let mut out = format!(
457        "HTTP/1.1 {} {}\r\n",
458        response.status_code, response.reason_phrase
459    )
460    .into_bytes();
461    for h in &response.headers {
462        out.extend_from_slice(h.name.as_bytes());
463        out.extend_from_slice(b": ");
464        out.extend_from_slice(h.value.as_bytes());
465        out.extend_from_slice(b"\r\n");
466    }
467    out.extend_from_slice(b"\r\n");
468    out
469}
470
471// ── Backend URL parsing ───────────────────────────────────────────────────────
472
473struct WsBackend {
474    /// `"host:port"` — passed to `to_socket_addrs()` for TCP connect.
475    addr: String,
476    /// Bare hostname (no port) — used for the `Host` header and TLS SNI.
477    host: String,
478    /// `true` when the URL scheme was `wss://`.
479    tls: bool,
480}
481
482impl WsBackend {
483    fn parse(url: &str) -> Option<Self> {
484        let (rest, tls, default_port) = if let Some(r) = url.strip_prefix("wss://") {
485            (r, true, 443u16)
486        } else if let Some(r) = url.strip_prefix("ws://") {
487            (r, false, 80u16)
488        } else {
489            (url, false, 80u16)
490        };
491
492        // Drop any path component.
493        let host_port = rest.split('/').next().unwrap_or(rest);
494
495        let (host, port) = if let Some(colon) = host_port.rfind(':') {
496            let port_str = &host_port[colon + 1..];
497            if let Ok(p) = port_str.parse::<u16>() {
498                (host_port[..colon].to_string(), p)
499            } else {
500                (host_port.to_string(), default_port)
501            }
502        } else {
503            (host_port.to_string(), default_port)
504        };
505
506        if host.is_empty() {
507            return None;
508        }
509
510        Some(WsBackend {
511            addr: format!("{}:{}", host, port),
512            host,
513            tls,
514        })
515    }
516}
517
518// ── WsBackend::parse unit tests ───────────────────────────────────────────────
519
520#[cfg(test)]
521mod backend_parse_tests {
522    use super::WsBackend;
523
524    fn parse(url: &str) -> Option<(String, String, bool)> {
525        WsBackend::parse(url).map(|b| (b.addr, b.host, b.tls))
526    }
527
528    #[test]
529    fn bare_host_port() {
530        assert_eq!(
531            Some(("chat:9000".into(), "chat".into(), false)),
532            parse("chat:9000")
533        );
534    }
535
536    #[test]
537    fn ws_scheme_plain() {
538        assert_eq!(
539            Some(("backend:3000".into(), "backend".into(), false)),
540            parse("ws://backend:3000")
541        );
542    }
543
544    #[test]
545    fn ws_scheme_default_port() {
546        assert_eq!(
547            Some(("api.example.com:80".into(), "api.example.com".into(), false)),
548            parse("ws://api.example.com")
549        );
550    }
551
552    #[test]
553    fn wss_scheme_sets_tls() {
554        assert_eq!(
555            Some(("secure.example.com:443".into(), "secure.example.com".into(), true)),
556            parse("wss://secure.example.com")
557        );
558    }
559
560    #[test]
561    fn wss_scheme_explicit_port() {
562        assert_eq!(
563            Some(("secure.example.com:8443".into(), "secure.example.com".into(), true)),
564            parse("wss://secure.example.com:8443")
565        );
566    }
567
568    #[test]
569    fn wss_default_port_is_443() {
570        let b = WsBackend::parse("wss://api.example.com").unwrap();
571        assert_eq!("api.example.com:443", b.addr);
572        assert_eq!("api.example.com", b.host);
573        assert!(b.tls);
574    }
575
576    #[test]
577    fn ws_default_port_is_80() {
578        let b = WsBackend::parse("ws://api.example.com").unwrap();
579        assert_eq!("api.example.com:80", b.addr);
580        assert!(!b.tls);
581    }
582
583    #[test]
584    fn empty_host_returns_none() {
585        assert_eq!(None, parse("wss://"));
586    }
587
588    #[test]
589    fn bare_host_no_port_defaults_to_80() {
590        assert_eq!(
591            Some(("myhost:80".into(), "myhost".into(), false)),
592            parse("myhost")
593        );
594    }
595
596    #[test]
597    fn path_component_is_ignored() {
598        // URL paths after host:port are stripped — only host:port matters.
599        let b = WsBackend::parse("ws://backend:9000/ws").unwrap();
600        assert_eq!("backend:9000", b.addr);
601        assert_eq!("backend", b.host);
602    }
603}
604
605// ── Live-backend-list (health check) tests ────────────────────────────────────
606
607#[cfg(test)]
608mod live_backends_tests {
609    use super::WsProxy;
610    use std::sync::{Arc, RwLock};
611
612    #[test]
613    fn new_picks_backends_in_round_robin_order() {
614        let proxy = WsProxy::new(["ws://a:1", "ws://b:1", "ws://c:1"]);
615        let picked: Vec<String> = (0..6)
616            .map(|_| proxy.pick_backend().unwrap().addr)
617            .collect();
618        assert_eq!(
619            vec!["a:1", "b:1", "c:1", "a:1", "b:1", "c:1"],
620            picked
621        );
622    }
623
624    #[test]
625    fn new_filters_out_unparseable_backends() {
626        // "wss://" alone has no host and WsBackend::parse rejects it.
627        let proxy = WsProxy::new(["ws://good:1", "wss://"]);
628        assert_eq!(vec!["ws://good:1".to_string()], proxy.all_backends);
629    }
630
631    #[test]
632    fn pick_backend_returns_none_when_live_list_is_empty() {
633        let proxy = WsProxy::with_live_backends(
634            vec!["ws://a:1".to_string()],
635            Arc::new(RwLock::new(vec![])),
636        );
637        assert!(proxy.pick_backend().is_none(), "no live backends means no pick");
638    }
639
640    #[test]
641    fn pick_backend_reflects_live_list_updates() {
642        // Simulates what proxy_config::health::start_health_checker does:
643        // mutate the shared live list out from under a running proxy.
644        let live = Arc::new(RwLock::new(vec!["ws://a:1".to_string(), "ws://b:1".to_string()]));
645        let proxy = WsProxy::with_live_backends(
646            vec!["ws://a:1".to_string(), "ws://b:1".to_string()],
647            Arc::clone(&live),
648        );
649
650        assert!(proxy.pick_backend().is_some());
651
652        // "a" fails its health check and is removed from rotation.
653        *live.write().unwrap() = vec!["ws://b:1".to_string()];
654        for _ in 0..4 {
655            assert_eq!("b:1", proxy.pick_backend().unwrap().addr);
656        }
657
658        // All backends fail — no live backend left.
659        live.write().unwrap().clear();
660        assert!(proxy.pick_backend().is_none());
661
662        // "a" recovers.
663        *live.write().unwrap() = vec!["ws://a:1".to_string()];
664        assert_eq!("a:1", proxy.pick_backend().unwrap().addr);
665    }
666}