Skip to content

Commit ada0679

Browse files
committed
Add tests for server ready state fix
1 parent f6a3c1b commit ada0679

4 files changed

Lines changed: 164 additions & 18 deletions

File tree

pytest_httpserver/blocking_httpserver.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class BlockingHTTPServer(HTTPServerBase):
4646
4747
:param timeout: waiting time in seconds for matching and responding to an incoming request.
4848
manager
49+
:param startup_timeout: maximum time in seconds to wait for server readiness.
50+
Set to ``None`` to disable readiness waiting.
4951
5052
.. py:attribute:: no_handler_status_code
5153
@@ -63,8 +65,10 @@ def __init__(
6365
port=DEFAULT_LISTEN_PORT,
6466
ssl_context: SSLContext | None = None,
6567
timeout: int = 30,
68+
*,
69+
startup_timeout: float | None = 10.0,
6670
):
67-
super().__init__(host, port, ssl_context)
71+
super().__init__(host, port, ssl_context, startup_timeout=startup_timeout)
6872
self.timeout = timeout
6973
self.request_queue: Queue[Request] = Queue()
7074
self.request_handlers: dict[Request, Queue[BlockingRequestHandler]] = {}

pytest_httpserver/httpserver.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import threading
99
import time
1010
import urllib.parse
11+
import warnings
1112
from collections import defaultdict
1213
from collections.abc import Iterable
1314
from collections.abc import Mapping
@@ -612,6 +613,8 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
612613
:param port: the TCP port where the server will listen
613614
:param ssl_context: the ssl context object to use for https connections
614615
:param threaded: whether to handle concurrent requests in separate threads
616+
:param startup_timeout: maximum time in seconds to wait for server readiness.
617+
Set to ``None`` to disable readiness waiting.
615618
616619
.. py:attribute:: log
617620
@@ -635,6 +638,7 @@ def __init__(
635638
ssl_context: SSLContext | None = None,
636639
*,
637640
threaded: bool = False,
641+
startup_timeout: float | None = 10.0,
638642
):
639643
"""
640644
Initializes the instance.
@@ -649,6 +653,7 @@ def __init__(
649653
self.log: list[tuple[Request, Response]] = []
650654
self.ssl_context = ssl_context
651655
self.threaded = threaded
656+
self.startup_timeout = startup_timeout
652657
self.no_handler_status_code = 500
653658
self._server_ready_event: threading.Event = threading.Event()
654659

@@ -732,8 +737,10 @@ def thread_target(self):
732737
733738
This should not be called directly, but can be overridden to tailor it to your needs.
734739
735-
If overriding, you must call ``self._server_ready_event.set()`` before starting
736-
to serve requests, otherwise :py:meth:`start` will raise an error after timeout.
740+
If overriding, you should call ``self._server_ready_event.set()`` before starting
741+
to serve requests. If the event is not set within the timeout, :py:meth:`start`
742+
will emit a warning if the thread is still alive; if the thread dies during
743+
startup, :py:meth:`start` raises an error.
737744
"""
738745
assert self.server is not None
739746
self._server_ready_event.set()
@@ -776,22 +783,33 @@ def start(self) -> None:
776783

777784
self.port = self.server.port # Update port (needed if `port` was set to 0)
778785
# Explicitly make the new thread daemonic to avoid shutdown issues
779-
self._server_ready_event.clear()
786+
# Create a new event for each startup to prevent stale threads from
787+
# signaling readiness for a subsequent start() attempt.
788+
self._server_ready_event = threading.Event()
780789
self.server_thread = threading.Thread(target=self.thread_target, daemon=True)
781790
self.server_thread.start()
782-
if not self._server_ready_event.wait(timeout=10):
783-
# Clean up the server before raising.
784-
# Use server_close() instead of shutdown() to avoid deadlock
785-
# if serve_forever() was never called.
786-
self.server.server_close()
787-
self.server_thread.join(timeout=5)
788-
self.server = None
789-
self.server_thread = None
790-
raise HTTPServerError(
791-
"Server did not start within timeout. "
792-
"If you override thread_target(), ensure it calls "
793-
"self._server_ready_event.set() before serving."
794-
)
791+
if self.startup_timeout is not None and not self._server_ready_event.wait(timeout=self.startup_timeout):
792+
# Event was not set within timeout.
793+
# Check if thread is still alive (custom thread_target may not set the event)
794+
if self.server_thread.is_alive():
795+
# Server thread is running, assume it's working (backward compatibility)
796+
warnings.warn(
797+
"Server thread is running but ready event was not set. "
798+
"If you override thread_target(), call self._server_ready_event.set() "
799+
"before serving to ensure reliable startup.",
800+
stacklevel=2,
801+
)
802+
else:
803+
# Thread died, clean up and raise
804+
self.server.server_close()
805+
self.server_thread.join(timeout=5)
806+
self.server = None
807+
self.server_thread = None
808+
raise HTTPServerError(
809+
"Server thread died during startup. "
810+
"If you override thread_target(), ensure it calls "
811+
"self._server_ready_event.set() before serving."
812+
)
795813

796814
def stop(self):
797815
"""
@@ -953,6 +971,8 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
953971
manager
954972
955973
:param threaded: whether to handle concurrent requests in separate threads
974+
:param startup_timeout: maximum time in seconds to wait for server readiness.
975+
Set to ``None`` to disable readiness waiting.
956976
957977
.. py:attribute:: no_handler_status_code
958978
@@ -972,11 +992,18 @@ def __init__(
972992
default_waiting_settings: WaitingSettings | None = None,
973993
*,
974994
threaded: bool = False,
995+
startup_timeout: float | None = 10.0,
975996
):
976997
"""
977998
Initializes the instance.
978999
"""
979-
super().__init__(host, port, ssl_context, threaded=threaded)
1000+
super().__init__(
1001+
host,
1002+
port,
1003+
ssl_context,
1004+
threaded=threaded,
1005+
startup_timeout=startup_timeout,
1006+
)
9801007

9811008
self.ordered_handlers: list[RequestHandler] = []
9821009
self.oneshot_handlers = RequestHandlerList()

tests/test_release.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def test_sdist_contents(build: Build, version: str):
232232
"test_querymatcher.py",
233233
"test_querystring.py",
234234
"test_release.py",
235+
"test_server_startup.py",
235236
"test_ssl.py",
236237
"test_thread_type.py",
237238
"test_threaded.py",

tests/test_server_startup.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import socket
5+
import time
6+
7+
import pytest
8+
9+
from pytest_httpserver import HTTPServer
10+
11+
12+
def test_server_ready_immediately_after_start():
13+
"""Test that the server accepts connections immediately after start() returns."""
14+
server = HTTPServer(host="localhost", port=0)
15+
server.expect_request("/").respond_with_data("ok")
16+
server.start()
17+
try:
18+
# Attempt to connect immediately - should not fail
19+
sock = socket.create_connection((server.host, server.port), timeout=1)
20+
sock.close()
21+
finally:
22+
server.stop()
23+
24+
25+
def test_server_ready_under_load():
26+
"""Test that the server is ready even when started multiple times in succession."""
27+
for _ in range(10):
28+
server = HTTPServer(host="localhost", port=0)
29+
server.expect_request("/").respond_with_data("ok")
30+
server.start()
31+
try:
32+
sock = socket.create_connection((server.host, server.port), timeout=1)
33+
sock.close()
34+
finally:
35+
server.stop()
36+
37+
38+
class SlowStartServer(HTTPServer):
39+
"""A server subclass that simulates slow startup."""
40+
41+
def thread_target(self):
42+
time.sleep(0.5) # Simulate slow initialization
43+
self._server_ready_event.set()
44+
assert self.server is not None
45+
self.server.serve_forever()
46+
47+
48+
class NoReadyEventServer(HTTPServer):
49+
"""A server subclass that never signals readiness."""
50+
51+
def thread_target(self):
52+
assert self.server is not None
53+
self.server.serve_forever()
54+
55+
56+
def test_slow_start_server_waits_for_ready():
57+
"""Test that start() waits for slow thread_target implementations."""
58+
server = SlowStartServer(host="localhost", port=0)
59+
server.expect_request("/").respond_with_data("ok")
60+
61+
start_time = time.time()
62+
server.start()
63+
elapsed = time.time() - start_time
64+
65+
try:
66+
# Should have waited at least 0.5 seconds
67+
assert elapsed >= 0.5
68+
# Server should be ready
69+
sock = socket.create_connection((server.host, server.port), timeout=1)
70+
sock.close()
71+
finally:
72+
server.stop()
73+
74+
75+
def test_new_event_created_for_each_start():
76+
"""Test that a new event is created for each start() to isolate retries."""
77+
server = HTTPServer(host="localhost", port=0)
78+
server.expect_request("/").respond_with_data("ok")
79+
80+
original_event = server._server_ready_event # noqa: SLF001
81+
82+
server.start()
83+
first_start_event = server._server_ready_event # noqa: SLF001
84+
server.stop()
85+
86+
server.start()
87+
second_start_event = server._server_ready_event # noqa: SLF001
88+
server.stop()
89+
90+
# Each start() should create a new event
91+
assert first_start_event is not original_event
92+
assert second_start_event is not first_start_event
93+
94+
95+
def test_warns_when_ready_event_not_set():
96+
"""Test that a warning is emitted when the ready event is never set."""
97+
server = NoReadyEventServer(host="localhost", port=0, startup_timeout=0.0)
98+
server.expect_request("/").respond_with_data("ok")
99+
100+
with pytest.warns(UserWarning, match="ready event was not set"):
101+
server.start()
102+
103+
try:
104+
deadline = time.time() + 1
105+
while time.time() < deadline:
106+
with contextlib.suppress(OSError):
107+
sock = socket.create_connection((server.host, server.port), timeout=0.1)
108+
sock.close()
109+
break
110+
time.sleep(0.01)
111+
else:
112+
raise AssertionError("Server did not accept connections within 1 second")
113+
finally:
114+
server.stop()

0 commit comments

Comments
 (0)