Skip to content

Commit e91ec1e

Browse files
skyvanguardclaude
andcommitted
fix: remove threading from test to avoid event loop conflicts
The test was using a separate ServerThread that created its own event loop via anyio.run(). This caused conflicts with the test's event loop in Python 3.10 with lowest-direct dependencies, resulting in: ValueError: The future belongs to a different loop than the one specified as the loop argument Replace ServerThread with an async context manager (run_app_lifespan) that runs the Starlette lifespan in the same event loop as the test. This is simpler and avoids cross-thread event loop issues. Also update pragma comments to 'lax no cover' for timeout handlers that have non-deterministic coverage in parallel test execution. Github-Issue: #1648 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 52cf90f commit e91ec1e

File tree

1 file changed

+14
-38
lines changed

1 file changed

+14
-38
lines changed

tests/issues/test_1648_client_disconnect_500.py

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
"""
99

1010
import logging
11-
import threading
1211
from collections.abc import AsyncGenerator
1312
from contextlib import asynccontextmanager
1413

@@ -67,26 +66,17 @@ async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
6766
return Starlette(routes=routes, lifespan=lifespan)
6867

6968

70-
class ServerThread(threading.Thread):
71-
"""Thread that runs the ASGI application lifespan."""
69+
@asynccontextmanager
70+
async def run_app_lifespan(app: Starlette) -> AsyncGenerator[None, None]:
71+
"""Run the Starlette app's lifespan context.
7272
73-
def __init__(self, app: Starlette):
74-
super().__init__(daemon=True)
75-
self.app = app
76-
self._stop_event = threading.Event()
77-
78-
def run(self) -> None:
79-
async def run_lifespan():
80-
lifespan_context = getattr(self.app.router, "lifespan_context", None)
81-
assert lifespan_context is not None
82-
async with lifespan_context(self.app):
83-
while not self._stop_event.is_set():
84-
await anyio.sleep(0.1)
85-
86-
anyio.run(run_lifespan)
87-
88-
def stop(self) -> None:
89-
self._stop_event.set()
73+
This manages the lifespan without using a separate thread, avoiding
74+
event loop conflicts that occur in Python 3.10 with older dependencies.
75+
"""
76+
lifespan_context = getattr(app.router, "lifespan_context", None)
77+
assert lifespan_context is not None
78+
async with lifespan_context(app):
79+
yield
9080

9181

9282
@pytest.mark.anyio
@@ -98,12 +88,8 @@ async def test_client_disconnect_does_not_produce_500(caplog: pytest.LogCaptureF
9888
logging it as ERROR, and returning HTTP 500.
9989
"""
10090
app = create_app()
101-
server_thread = ServerThread(app)
102-
server_thread.start()
103-
104-
try:
105-
await anyio.sleep(0.2)
10691

92+
async with run_app_lifespan(app):
10793
with caplog.at_level(logging.DEBUG):
10894
async with httpx.AsyncClient(
10995
transport=httpx.ASGITransport(app=app),
@@ -125,7 +111,7 @@ async def test_client_disconnect_does_not_produce_500(caplog: pytest.LogCaptureF
125111
"Content-Type": "application/json",
126112
},
127113
)
128-
except (httpx.ReadTimeout, httpx.ReadError): # pragma: no cover
114+
except (httpx.ReadTimeout, httpx.ReadError): # pragma: lax no cover
129115
pass # Expected - client timed out
130116

131117
# Wait briefly for any async error logging to complete
@@ -136,21 +122,14 @@ async def test_client_disconnect_does_not_produce_500(caplog: pytest.LogCaptureF
136122
assert not error_records, (
137123
f"Server logged ERROR for client disconnect: {[r.getMessage() for r in error_records]}"
138124
)
139-
finally:
140-
server_thread.stop()
141-
server_thread.join(timeout=2)
142125

143126

144127
@pytest.mark.anyio
145128
async def test_server_healthy_after_client_disconnect():
146129
"""Server should remain healthy and accept new requests after a client disconnects."""
147130
app = create_app()
148-
server_thread = ServerThread(app)
149-
server_thread.start()
150-
151-
try:
152-
await anyio.sleep(0.2)
153131

132+
async with run_app_lifespan(app):
154133
async with httpx.AsyncClient(
155134
transport=httpx.ASGITransport(app=app),
156135
base_url="http://testserver",
@@ -171,7 +150,7 @@ async def test_server_healthy_after_client_disconnect():
171150
"Content-Type": "application/json",
172151
},
173152
)
174-
except (httpx.ReadTimeout, httpx.ReadError): # pragma: no cover
153+
except (httpx.ReadTimeout, httpx.ReadError): # pragma: lax no cover
175154
pass # Expected - client timed out
176155

177156
# Create a new client for the second request
@@ -199,6 +178,3 @@ async def test_server_healthy_after_client_disconnect():
199178
},
200179
)
201180
assert response.status_code == 200
202-
finally:
203-
server_thread.stop()
204-
server_thread.join(timeout=2)

0 commit comments

Comments
 (0)