Skip to content

Do not hang forever if test server fails to start - #3775

Open
akx wants to merge 2 commits into
encode:masterfrom
akx:test-server-fix
Open

akx wants to merge 2 commits into
encode:masterfrom
akx:test-server-fix

Conversation

@akx

@akx akx commented Feb 24, 2026

Copy link
Copy Markdown

Summary

I was wondering why the test suite just hung forever, and finally with pytest tests/ -vvvx --log-cli-level=DEBUG -s I found that:

[Errno 48] error while attempting to bind on address ('127.0.0.1', 8000): [errno 48] address already in use

– yep, I was working on something else on port 8000 – and so, server.running never becomes True, and tests just hang.

This PR:

  • Makes sure errors from server.run() (in the secondary thread) are caught and propagated immediately to the controlling loop
  • Adds timeouts, so even if something even more unexpected happens, the suite will error out instead of hanging forever.

Checklist

  • I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
    • No separate tests for testing the test fixture 😄
  • I've updated the documentation accordingly.
    • No user-facing or developer documentation changes needed. (Noting that port 8000 must not be in use is a separate thing to maybe document, I think...)

@akx
akx force-pushed the test-server-fix branch 4 times, most recently from 76054c5 to b7dd358 Compare February 24, 2026 14:03
@lovelydinosaur

lovelydinosaur commented Feb 24, 2026 •

Copy link
Copy Markdown
Member

@akx Thanks... I've shifted my development efforts over to this comprehensive redesign, due to it's significantly reduced complexity.

Edit: Updated links.

Comment thread tests/conftest.py
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.

@chiragjangiir chiragjangiir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review: Do not hang forever if test server fails to start

1. Issue Addressed

In the test suite, test servers (Server fixture) spin up in a background thread or process and wait on a readiness condition. If port binding failed (e.g., port collision in CI) or an unhandled exception occurred during startup, the test runner would block indefinitely waiting for server readiness.

2. Fix Details

  • Adds a finite timeout (e.g., 5 seconds) to server.wait_until_ready().
  • Captures startup exceptions on the server thread and re-raises them in the main test thread if initialization fails.
  • Prevents CI runners from timing out after 60+ minutes on flaky port allocations.

3. Recommendation

High-value improvement for developer experience and CI reliability. Approved.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants