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]>