Skip to content

Commit b66b4ea

Browse files
committed
test: pre-bind socket in run_uvicorn_in_thread, drop polling loop
The previous version polled server.started with time.sleep(0.001) until uvicorn finished binding. We were waiting for uvicorn to tell us the port — but we can just bind the socket ourselves and hand it to uvicorn via server.run(sockets=[sock]). Once sock.listen() returns, the kernel queues incoming connections (up to the backlog). If a client connects before uvicorn's event loop reaches accept(), the connection sits in the accept queue and is picked up as soon as uvicorn is ready. The kernel is the synchronizer — no cross-thread flag needed. The port is now known before the thread starts. No polling, no sleep, no wait at all.
1 parent 49db5ec commit b66b4ea

File tree

1 file changed

+20
-36
lines changed

1 file changed

+20
-36
lines changed

tests/test_helpers.py

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,60 +9,44 @@
99

1010
import uvicorn
1111

12-
# How long to wait for the uvicorn server thread to reach `started`.
13-
# Generous to absorb CI scheduling delays — actual startup is typically <100ms.
14-
_SERVER_START_TIMEOUT_S = 20.0
1512
_SERVER_SHUTDOWN_TIMEOUT_S = 5.0
1613

1714

1815
@contextmanager
1916
def run_uvicorn_in_thread(app: Any, **config_kwargs: Any) -> Generator[str, None, None]:
20-
"""Run a uvicorn server in a background thread with an ephemeral port.
17+
"""Run a uvicorn server in a background thread on an ephemeral port.
2118
22-
This eliminates the TOCTOU race that occurs when a test picks a free port
23-
with ``socket.bind((host, 0))``, releases it, then starts a server hoping
24-
to rebind the same port — between release and rebind, another pytest-xdist
25-
worker may claim it, causing connection errors or cross-test contamination.
19+
The socket is bound and put into listening state *before* the thread
20+
starts, so the port is known immediately with no wait. The kernel's
21+
listen queue buffers any connections that arrive before uvicorn's event
22+
loop reaches ``accept()``, so callers can connect as soon as this
23+
function yields — no polling, no sleeps, no startup race.
2624
27-
With ``port=0``, the OS atomically assigns a free port at bind time; the
28-
server holds it from that moment until shutdown. We read the actual port
29-
back from uvicorn's bound socket after startup completes.
25+
This also avoids the TOCTOU race of the old pick-a-port-then-rebind
26+
pattern: the socket passed here is the one uvicorn serves on, with no
27+
gap where another pytest-xdist worker could claim it.
3028
3129
Args:
3230
app: ASGI application to serve.
3331
**config_kwargs: Additional keyword arguments for :class:`uvicorn.Config`
34-
(e.g. ``log_level``, ``limit_concurrency``). ``host`` defaults to
35-
``127.0.0.1`` and ``port`` is forced to 0.
32+
(e.g. ``log_level``). ``host``/``port`` are ignored since the
33+
socket is pre-bound.
3634
3735
Yields:
3836
The base URL of the running server, e.g. ``http://127.0.0.1:54321``.
39-
40-
Raises:
41-
TimeoutError: If the server does not start within 20 seconds.
42-
RuntimeError: If the server thread dies during startup.
4337
"""
44-
config_kwargs.setdefault("host", "127.0.0.1")
38+
host = "127.0.0.1"
39+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
40+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
41+
sock.bind((host, 0))
42+
sock.listen()
43+
port = sock.getsockname()[1]
44+
4545
config_kwargs.setdefault("log_level", "error")
46-
config = uvicorn.Config(app=app, port=0, **config_kwargs)
47-
server = uvicorn.Server(config=config)
46+
server = uvicorn.Server(config=uvicorn.Config(app=app, **config_kwargs))
4847

49-
thread = threading.Thread(target=server.run, daemon=True)
48+
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
5049
thread.start()
51-
52-
# uvicorn sets `server.started = True` at the end of `Server.startup()`,
53-
# after sockets are bound and the lifespan startup phase has completed.
54-
start = time.monotonic()
55-
while not server.started:
56-
if time.monotonic() - start > _SERVER_START_TIMEOUT_S: # pragma: no cover
57-
raise TimeoutError(f"uvicorn server failed to start within {_SERVER_START_TIMEOUT_S}s")
58-
if not thread.is_alive(): # pragma: no cover
59-
raise RuntimeError("uvicorn server thread exited during startup")
60-
time.sleep(0.001)
61-
62-
# server.servers[0] is the asyncio.Server; its bound socket has the real port
63-
port = server.servers[0].sockets[0].getsockname()[1]
64-
host = config.host
65-
6650
try:
6751
yield f"http://{host}:{port}"
6852
finally:

0 commit comments

Comments
 (0)