Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,16 +268,43 @@ async def watch_restarts(self) -> None: # pragma: no cover
await self.startup()


def serve_in_thread(server: TestServer) -> typing.Iterator[TestServer]:
thread = threading.Thread(target=server.run)
def serve_in_thread(
server: TestServer,
*,
timeout: float = 10.0,
) -> typing.Iterator[TestServer]:
server_exception = None
server_caught_exception = threading.Event()

def _run_server() -> None:
nonlocal server_exception
try:
server.run()
except BaseException as exc: # pragma: nocover
# BaseException as we need to catch SystemExit too;
# `uvicorn` calls `sys.exit(1)` at failure.
server_exception = exc
server_caught_exception.set()

thread = threading.Thread(target=_run_server)
thread.start()

try:
while not server.started:
start_time = time.time()
while True:
if server.started:
break
if server_caught_exception.wait(1e-3): # pragma: nocover
raise RuntimeError(
f"Server failed to start: {server_exception!r}",
) from server_exception
if time.time() - start_time > timeout: # pragma: nocover
raise TimeoutError("Server did not start in time")
time.sleep(1e-3)
yield server
finally:
server.should_exit = True
thread.join()
thread.join(timeout=timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the timeout can still leave the test process hanging if server.run() gets stuck during startup.

The server thread is created as a non-daemon thread. If startup never completes, the timeout raises as expected, and the finally block waits for the thread for another timeout seconds. But if the thread is still blocked, join() returns while that non-daemon thread remains alive.

Python will then wait for the thread during interpreter shutdown, so the test process can still hang indefinitely.

I reproduced the underlying behavior with a blocked non-daemon worker: a timed join() returned, but the Python process stayed alive.

Could we ensure the server thread cannot keep the process alive after a startup timeout, perhaps by making it a daemon thread? A regression test with a deliberately stalled startup would help cover this case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth comparing the complexity here...

httpx/tests/conftest.py

Lines 224 to 287 in b5addb6

class TestServer(Server):
@property
def url(self) -> httpx.URL:
protocol = "https" if self.config.is_ssl else "http"
return httpx.URL(f"{protocol}://{self.config.host}:{self.config.port}/")
def install_signal_handlers(self) -> None:
# Disable the default installation of handlers for signals such as SIGTERM,
# because it can only be done in the main thread.
pass # pragma: nocover
async def serve(self, sockets=None):
self.restart_requested = asyncio.Event()
loop = asyncio.get_event_loop()
tasks = {
loop.create_task(super().serve(sockets=sockets)),
loop.create_task(self.watch_restarts()),
}
await asyncio.wait(tasks)
async def restart(self) -> None: # pragma: no cover
# This coroutine may be called from a different thread than the one the
# server is running on, and from an async environment that's not asyncio.
# For this reason, we use an event to coordinate with the server
# instead of calling shutdown()/startup() directly, and should not make
# any asyncio-specific operations.
self.started = False
self.restart_requested.set()
while not self.started:
await sleep(0.2)
async def watch_restarts(self) -> None: # pragma: no cover
while True:
if self.should_exit:
return
try:
await asyncio.wait_for(self.restart_requested.wait(), timeout=0.1)
except asyncio.TimeoutError:
continue
self.restart_requested.clear()
await self.shutdown()
await self.startup()
def serve_in_thread(server: TestServer) -> typing.Iterator[TestServer]:
thread = threading.Thread(target=server.run)
thread.start()
try:
while not server.started:
time.sleep(1e-3)
yield server
finally:
server.should_exit = True
thread.join()
@pytest.fixture(scope="session")
def server() -> typing.Iterator[TestServer]:
config = Config(app=app, lifespan="off", loop="asyncio")
server = TestServer(config=config)
yield from serve_in_thread(server)

vs. what we now have with hip...

@pytest.fixture
def server():
    with hip.Server(echo) as server:
        yield server

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the context. The HIP fixture is definitely much simpler and avoids much of the complexity involved in managing server threads manually.

My earlier comment was specific to the current HTTPX fixture, in case this PR is still being considered. I appreciate the clarification about the direction of development.



@pytest.fixture(scope="session")
Expand Down