|
9 | 9 |
|
10 | 10 | import uvicorn |
11 | 11 |
|
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 |
15 | 12 | _SERVER_SHUTDOWN_TIMEOUT_S = 5.0 |
16 | 13 |
|
17 | 14 |
|
18 | 15 | @contextmanager |
19 | 16 | 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. |
21 | 18 |
|
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. |
26 | 24 |
|
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. |
30 | 28 |
|
31 | 29 | Args: |
32 | 30 | app: ASGI application to serve. |
33 | 31 | **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. |
36 | 34 |
|
37 | 35 | Yields: |
38 | 36 | 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. |
43 | 37 | """ |
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 | + |
45 | 45 | 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)) |
48 | 47 |
|
49 | | - thread = threading.Thread(target=server.run, daemon=True) |
| 48 | + thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) |
50 | 49 | 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 | | - |
66 | 50 | try: |
67 | 51 | yield f"http://{host}:{port}" |
68 | 52 | finally: |
|
0 commit comments