Skip to content

Commit 56fcfe0

Browse files
committed
fix(tests): suppress uvicorn's 3.14 DeprecationWarning in thread, rejoin
Two CI failures, same root cause of running uvicorn in-process instead of a subprocess: 1. Python 3.14: asyncio.iscoroutinefunction is deprecated. uvicorn's config.load() calls it, emitting a DeprecationWarning. Pytest's filterwarnings=error applies to the uvicorn thread (same process), so the warning becomes an exception that kills the thread before server.started is set. The old subprocess fixtures masked this — subprocesses don't inherit pytest's warning filters. Fix: wrap server.run in a catch_warnings block that ignores DeprecationWarning. Also detect a dead thread in the startup poll so we fail fast rather than spinning for 20s on future thread crashes. 2. Windows 3.12/3.13: Proactor pipe transports leak as unclosed sockets when the daemon thread is reaped before its event loop finishes shutdown. The prior perf commit removed thread.join() to save ~200ms per fixture, but that doesn't hold on Windows. Bring back the join. Module-scoped fixtures absorb most of the cost anyway (one server per module instead of per-test).
1 parent dc154d5 commit 56fcfe0

File tree

1 file changed

+28
-14
lines changed

1 file changed

+28
-14
lines changed

tests/test_helpers.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,28 @@ def run_server_in_thread(app: ASGIApp, lifespan: Literal["auto", "on", "off"] =
6767
config = uvicorn.Config(app=app, host="127.0.0.1", port=0, log_level="error", lifespan=lifespan)
6868
server = uvicorn.Server(config=config)
6969

70-
thread = threading.Thread(target=server.run, daemon=True)
70+
def _run() -> None:
71+
# On Python 3.14, asyncio.iscoroutinefunction raises a DeprecationWarning
72+
# when uvicorn's config.load() calls it. Pytest's `filterwarnings = error`
73+
# applies to this thread (same process), so the warning becomes an
74+
# exception that kills the thread before server.started is set. The old
75+
# subprocess fixtures masked this because subprocess doesn't inherit
76+
# pytest's warning filters. catch_warnings is thread-local since 3.12;
77+
# on 3.10/3.11 it's process-global but the warning doesn't fire there.
78+
with warnings.catch_warnings():
79+
warnings.filterwarnings("ignore", category=DeprecationWarning)
80+
server.run()
81+
82+
thread = threading.Thread(target=_run, daemon=True)
7183
thread.start()
7284

73-
# Wait for uvicorn to bind and start accepting connections
85+
# Wait for uvicorn to bind and start accepting connections. If the thread
86+
# dies (e.g., config.load() raised), bail early with the thread's exception
87+
# rather than spinning for 20s.
7488
start_time = time.time()
7589
while not server.started:
90+
if not thread.is_alive(): # pragma: no cover
91+
raise RuntimeError("uvicorn thread exited before server started")
7692
if time.time() - start_time > 20.0: # pragma: no cover
7793
raise TimeoutError("uvicorn server did not start within 20 seconds")
7894
time.sleep(0.01)
@@ -82,20 +98,18 @@ def run_server_in_thread(app: ASGIApp, lifespan: Literal["auto", "on", "off"] =
8298
try:
8399
yield f"http://127.0.0.1:{port}"
84100
finally:
101+
# force_exit skips uvicorn's graceful connection drain. We still join
102+
# the thread: on Windows, Proactor pipe transports leak as unclosed
103+
# sockets if the daemon thread is reaped before its event loop finishes
104+
# shutdown. Module-scoped fixtures make the ~200ms join cost acceptable
105+
# (one server per module instead of one per test).
85106
server.should_exit = True
86107
server.force_exit = True
87-
# Don't block on thread.join() — uvicorn polls should_exit every 0.1s and
88-
# its shutdown() adds another 0.1s, totaling ~200ms teardown latency per
89-
# fixture. The thread is a daemon so it will be reaped by the interpreter;
90-
# we just signal exit and move on. The socket is closed by uvicorn's
91-
# shutdown regardless, so the port is released for the next test.
92-
# When uvicorn shuts down with in-flight SSE connections, the server
93-
# cancels request handlers mid-operation. SseServerTransport's internal
94-
# memory streams may not get their `finally` cleanup run before GC,
95-
# causing ResourceWarnings. These are artifacts of test abrupt-disconnect
96-
# patterns (open SSE stream → check status → exit without consuming),
97-
# not bugs. Force GC here and suppress the warnings so they don't leak
98-
# into the next test's PytestUnraisableExceptionWarning collector.
108+
thread.join(timeout=5)
109+
# SseServerTransport's internal memory streams may be GC'd without
110+
# finalizers when uvicorn cancels in-flight SSE handlers. Force GC here
111+
# and suppress the ResourceWarnings so they don't leak into the next
112+
# test's PytestUnraisableExceptionWarning collector.
99113
with warnings.catch_warnings():
100114
warnings.simplefilter("ignore", ResourceWarning)
101115
gc.collect()

0 commit comments

Comments
 (0)