open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/start_ports.rs
Transaction Science 6675337c84 joule-moq-relay: a start refused part way returns with every port it bound free
RelayServer::start spawned each QUIC endpoint's accept loop, then bound the WebSocket listener,
then started the control surface.
- Refused at the WebSocket bind, its `?` dropped the accept loop's handle. That leaves the task
  running with the endpoint and its UDP socket for the life of the process.
- Refused at the control surface, it told the tasks to go but did not wait for them.

A start that waits out a port another process holds by retrying then failed every retry on the
UDP port its own first try kept. wt-relay's run::start retries 40 times, and wt-api sets
websocket_listen in deployment.

A refused start is now shut down as a relay is (RelayServer::shutdown) before its error
returns: every task it spawned has ended and every port it bound is free. Control::start
refused part way (the control port, a file in the socket's place, SIGHUP) now closes the TCP
listener and tasks it had started; before, its `?` left them running. Control is Default
(serving nothing).

New tests/start_ports.rs, from a task on a runtime of one worker, on fixed ports below the
range handed out for port 0:
- The WebSocket port held, the control port held, a file where the control socket goes. Each
  time, every other port binds at once when the refusal returns, and a start with nothing held
  comes up on all of them. 10 rounds.
- The control surface alone, refused at its socket 20 times; its TCP port binds at once each
  time. This one also catches a Control::start that only drops what it started without
  awaiting it (mutation run on Linux).

Fail-first on unfixed origin/main (b56b175e9, Linux): both failed at round 0:
- "round 0, the WebSocket port held: UDP 127.0.0.1:21768 is still bound when the refused start
  returns: Address already in use (os error 98)"
- "round 0, the control surface refused: TCP 127.0.0.1:21896 is still bound ..."

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:49:49 -05:00

139 lines
7.5 KiB
Rust

//! A start refused part way returns its error with every port it bound free, so a start that
//! follows binds them at once: a relay restarting on fixed ports (a rollover) whose
//! WebSocket port, control port or control socket is not yet to be had. The relay binds its
//! UDP endpoint, then its WebSocket listener, then its control surface; each check holds one of
//! those, starts the relay on all of them, and binds every other at once when the start
//! returns. Each test runs in a task on a runtime of one worker, as a restart from a spawned
//! task does: there a task a refused start only told to go (`JoinHandle::abort`), or a QUIC
//! endpoint whose driver has yet to see its last handle go, cannot have let its socket go
//! until the caller yields, and one it did not end at all holds its port for the life of the
//! process. The control surface is held alone as well: refused at its socket, it closes the TCP
//! listener it had bound.
use joule_moq_relay::{RelayConfig, RelayServer};
use std::net::SocketAddr;
use std::time::Duration;
use tokio::time::timeout;
const ROUNDS: usize = 10;
/// Bind each address at once, as a start following the refused one does first.
fn free(tcp: &[SocketAddr], udp: &[SocketAddr], why: &str) {
for &a in tcp {
if let Err(e) = std::net::TcpListener::bind(a) {
panic!("{why}: TCP {a} is still bound when the refused start returns: {e}");
}
}
for &a in udp {
if let Err(e) = std::net::UdpSocket::bind(a) {
panic!("{why}: UDP {a} is still bound when the refused start returns: {e}");
}
}
}
/// A port below the range a system hands out for port 0 (from 32768 on Linux, 49152 on
/// macOS) that `free` finds free now. A port handed out for port 0 can go to any socket on the
/// host the moment it is let go, so a test that lets a port go and checks at once that it is
/// free, or binds it again, takes one of these instead. Each call starts its search at a place
/// of its own, so tests of one binary running at once are not handed the same port. Even ports
/// only, so a pair (P, P + 1) can be asked for.
fn low_port(free: impl Fn(u16) -> bool) -> u16 {
static CALLS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let first = std::process::id() % 2_000 + 64 * CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
(0..6_000u32)
.map(|i| (20_000 + 2 * ((first + i) % 6_000)) as u16)
.find(|&p| free(p))
.expect("a free port below the range handed out for port 0")
}
fn low_tcp() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], low_port(|p| std::net::TcpListener::bind(("127.0.0.1", p)).is_ok())))
}
fn low_udp() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], low_port(|p| std::net::UdpSocket::bind(("127.0.0.1", p)).is_ok())))
}
async fn refused(cfg: RelayConfig, why: &str) -> String {
match RelayServer::start(cfg).await {
Ok(_) => panic!("{why}: the relay starts"),
Err(e) => e,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn a_start_refused_part_way_frees_every_port_it_bound() {
let t = tokio::spawn(async {
// Fixed ports below the range handed out for port 0, for every start.
let mut cfg = RelayConfig::loopback();
let (quic, ws, control) = (low_udp(), low_tcp(), low_tcp());
(cfg.quic_listen, cfg.websocket_listen, cfg.control.listen) = (Some(quic), Some(ws), Some(control));
let dir = std::env::temp_dir().join(format!("relay-start-ports-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for round in 0..ROUNDS {
// The WebSocket port held: the UDP endpoint was bound before it.
let held = std::net::TcpListener::bind(ws).unwrap();
let e = refused(cfg.clone(), "the WebSocket port held").await;
assert!(e.contains(&ws.to_string()), "round {round}: the refusal names the WebSocket port: {e}");
free(&[control], &[quic], &format!("round {round}, the WebSocket port held"));
drop(held);
// The control port held: the UDP endpoint and the WebSocket listener were bound
// before it.
let held = std::net::TcpListener::bind(control).unwrap();
let e = refused(cfg.clone(), "the control port held").await;
assert!(e.contains(&control.to_string()), "round {round}: the refusal names the control port: {e}");
free(&[ws], &[quic], &format!("round {round}, the control port held"));
drop(held);
// A file that is not a socket where the control socket goes: the control surface's
// TCP listener was bound before it, and the file is left as it was.
#[cfg(unix)]
{
let path = dir.join("control.sock");
std::fs::write(&path, b"not a socket").unwrap();
let mut c = cfg.clone();
c.control.socket = Some(path.clone());
let e = refused(c, "a file in the control socket's place").await;
assert!(e.contains("not a socket"), "round {round}: the refusal names the control socket: {e}");
free(&[ws, control], &[quic], &format!("round {round}, a file in the control socket's place"));
assert_eq!(std::fs::read(&path).unwrap(), b"not a socket", "round {round}: the file is left as it was");
std::fs::remove_file(&path).unwrap();
}
// Nothing held: a start on every one of the ports comes up at once.
let server = RelayServer::start(cfg.clone()).await.unwrap_or_else(|e| panic!("round {round}: the relay starts on the ports a refused start bound: {e}"));
assert_eq!((server.quic_addr, server.websocket_addr, server.control_addr), (Some(quic), Some(ws), Some(control)));
server.shutdown().await;
free(&[ws, control], &[quic], &format!("round {round}, the shutdown"));
}
let _ = std::fs::remove_dir_all(&dir);
});
timeout(Duration::from_secs(300), t).await.expect("in time").unwrap();
}
/// The control surface alone, refused part way (a file that is not a socket where its socket
/// goes): the TCP listener it bound first is closed when the refusal returns, its task ended, not
/// only told to go.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn a_control_surface_refused_part_way_frees_its_port() {
let t = tokio::spawn(async {
let dir = std::env::temp_dir().join(format!("relay-control-ports-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("control.sock");
std::fs::write(&path, b"not a socket").unwrap();
let at = low_tcp();
let mut cfg = RelayConfig::loopback();
cfg.control.listen = Some(at);
cfg.control.socket = Some(path.clone());
let relay = joule_moq_relay::Relay::new(cfg).unwrap();
for round in 0..ROUNDS * 2 {
match joule_moq_relay::control::Control::start(&relay).await {
Ok(_) => panic!("round {round}: the control surface starts"),
Err(e) => assert!(e.contains("not a socket"), "round {round}: the refusal names the control socket: {e}"),
}
free(&[at], &[], &format!("round {round}, the control surface refused"));
}
assert_eq!(std::fs::read(&path).unwrap(), b"not a socket", "the file is left as it was");
let _ = std::fs::remove_dir_all(&dir);
});
timeout(Duration::from_secs(120), t).await.expect("in time").unwrap();
}