Skip to content

Commit 51afae6

Browse files
committed
Add tests for server ready state fix
1 parent 69d9374 commit 51afae6

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: int = DEFAULT_LISTEN_PORT,
6466
ssl_context: SSLContext | None = None,
6567
timeout: int = 30,
68+
*,
69+
startup_timeout: float | None = 10.0,
6670
) -> None:
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 Callable
1314
from collections.abc import Generator
@@ -615,6 +616,8 @@ class HTTPServerBase(abc.ABC): # pylint: disable=too-many-instance-attributes
615616
:param port: the TCP port where the server will listen
616617
:param ssl_context: the ssl context object to use for https connections
617618
:param threaded: whether to handle concurrent requests in separate threads
619+
:param startup_timeout: maximum time in seconds to wait for server readiness.
620+
Set to ``None`` to disable readiness waiting.
618621
619622
.. py:attribute:: log
620623
@@ -638,6 +641,7 @@ def __init__(
638641
ssl_context: SSLContext | None = None,
639642
*,
640643
threaded: bool = False,
644+
startup_timeout: float | None = 10.0,
641645
) -> None:
642646
"""
643647
Initializes the instance.
@@ -652,6 +656,7 @@ def __init__(
652656
self.log: list[tuple[Request, Response]] = []
653657
self.ssl_context = ssl_context
654658
self.threaded = threaded
659+
self.startup_timeout = startup_timeout
655660
self.no_handler_status_code = 500
656661
self._server_ready_event: threading.Event = threading.Event()
657662

@@ -735,8 +740,10 @@ def thread_target(self) -> None:
735740
736741
This should not be called directly, but can be overridden to tailor it to your needs.
737742
738-
If overriding, you must call ``self._server_ready_event.set()`` before starting
739-
to serve requests, otherwise :py:meth:`start` will raise an error after timeout.
743+
If overriding, you should call ``self._server_ready_event.set()`` before starting
744+
to serve requests. If the event is not set within the timeout, :py:meth:`start`
745+
will emit a warning if the thread is still alive; if the thread dies during
746+
startup, :py:meth:`start` raises an error.
740747
"""
741748
assert self.server is not None
742749
self._server_ready_event.set()
@@ -779,22 +786,33 @@ def start(self) -> None:
779786

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

799817
def stop(self) -> None:
800818
"""
@@ -956,6 +974,8 @@ class HTTPServer(HTTPServerBase): # pylint: disable=too-many-instance-attribute
956974
manager
957975
958976
:param threaded: whether to handle concurrent requests in separate threads
977+
:param startup_timeout: maximum time in seconds to wait for server readiness.
978+
Set to ``None`` to disable readiness waiting.
959979
960980
.. py:attribute:: no_handler_status_code
961981
@@ -975,11 +995,18 @@ def __init__(
975995
default_waiting_settings: WaitingSettings | None = None,
976996
*,
977997
threaded: bool = False,
998+
startup_timeout: float | None = 10.0,
978999
) -> None:
9791000
"""
9801001
Initializes the instance.
9811002
"""
982-
super().__init__(host, port, ssl_context, threaded=threaded)
1003+
super().__init__(
1004+
host,
1005+
port,
1006+
ssl_context,
1007+
threaded=threaded,
1008+
startup_timeout=startup_timeout,
1009+
)
9831010

9841011
self.ordered_handlers: list[RequestHandler] = []
9851012
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)