Skip to content

Wait for the server to accept connections in start_server - #287

Draft
evnchn wants to merge 1 commit into
mainfrom
fix/screen-start-server-readiness
Draft

Wait for the server to accept connections in start_server#287
evnchn wants to merge 1 commit into
mainfrom
fix/screen-start-server-readiness

Conversation

@evnchn

@evnchn evnchn commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Drafted by Claude Code on evnchn's behalf.

Fork staging PR — for review before anything goes upstream.

Motivation

Screen.start_server() returns before the webserver is accepting connections. 12 call sites across 5 test files call screen.start_server() and then immediately issue an HTTP request, so every one of them races an unsynchronised window.

Observed in the merge queue (run 30927252265, py3.13) in tests/test_sub_pages.py::test_http_404_on_initial_request:

screen.start_server()
assert httpx.get(f'http://localhost:{Screen.PORT}/').status_code == 200
E   httpx.ConnectError: [Errno 111] Connection refused

The method had no readiness synchronisation at all — it started a thread and returned:

self.server_thread.start()

open() tolerates this because it retries selenium.get() against a deadline. Direct-HTTP callers have no such protection; they just happen to win the race because client-side connection setup usually outlasts uvicorn's bind.

Measured window (instrumented locally, 8 runs): start_server() returned after 0.2–1.5 ms, but the port only accepted connections after 4–87 ms — an unguarded gap of 3.4–86.4 ms on every call, worst on the first (cold) run. That is the window a loaded CI runner loses.

Implementation

Wait for the port to accept connections before returning, bounded by a timeout parameter:

self.server_thread.start()
deadline = time.time() + timeout
while not helpers.is_port_open('localhost', self.PORT):
    if not self.server_thread.is_alive():
        raise RuntimeError('The NiceGUI server has stopped running')
    if time.time() > deadline:
        raise RuntimeError(f'The NiceGUI server did not start listening on port {self.PORT} within {timeout} s')
    time.sleep(0.01)

This reuses the existing helpers.is_port_open() and mirrors the deadline/liveness idiom already in open(). All 12 call sites are fixed with no changes to the tests themselves.

Port readiness is used rather than uvicorn.Server.started deliberately: Server.instance is a class attribute that survives between tests, so a stale instance from a previous test could report started is True and make the wait a silent no-op. TCP readiness is also the exact condition the failing callers need.

Both branches of start_server() are covered — the runpy branch serves on the same port because ui_run.py forces port = NICEGUI_SCREEN_TEST_PORT under pytest.

Verification

Measured window, before and after (same instrument, 8 runs each):

start_server() returns port listening unguarded window
before 0.2–1.5 ms 4.0–86.7 ms 3.4–86.4 ms
after 14.6–101.2 ms 14.8–101.7 ms 0.2–0.5 ms

The 0.2–0.5 ms residual is the measuring probe's own latency, not a real gap.

Regression test (tests/test_screen.py, new) — deterministic, and seen to fail without the fix:

with fix    : 6/6 passed
without fix : 6/6 failed  — AssertionError: assert False
                            where False = is_port_open('localhost', 59217)

No regressions: the 5 files containing all 12 start_server() call sites pass, and examples/pytests (which exercises the runpy branch) passes 10/10.

Gates: pre-commit all Passed · mypy clean over 245 source files · pylint 10.00/10.

The 12 exposed call sites

grep -rn -A 2 "screen.start_server()" tests/ | grep -cE "httpx\.(get|post)"12, across:

  • tests/test_sub_pages.py (the one that actually failed in CI)
  • tests/test_status_code.py
  • tests/test_page.py
  • tests/test_timer.py
  • tests/test_event.py

Each does start_server() followed immediately by an HTTP request. They are all exposed to the same race; only one has been unlucky in the queue so far.

Second-lineage review (Codex)

8/10, no MUST-FIX, verdict "fit to put in front of a maintainer". It confirmed the Server.instance.started staleness reasoning, agreed the dead-thread check cannot false-positive (is_alive() is true from start() until run() exits), and found no caller depending on the old non-blocking behaviour.

Its one finding — that the timeout error message should state the bound actually used — is applied above (within {timeout} s).

Progress

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added/updated.
  • Documentation is not necessary — internal test-harness behaviour.
  • No breaking changes to the public API. start_server() keeps its signature (the timeout parameter is optional) and only becomes stricter about when it returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant