From 3d8361a988ef14230629146a95b7f5eac3462250 Mon Sep 17 00:00:00 2001 From: Shahadat Islam Koyel <179203101+ShahadatTest@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:29:53 +0600 Subject: [PATCH 1/3] Wait until consumed response handlers finish --- pytest_httpserver/httpserver.py | 51 +++++++++------ ...t-handler-completion-0a17b0e62fbb76a1.yaml | 5 ++ tests/test_wait.py | 63 ++++++++++++++++++- 3 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 releasenotes/notes/wait-handler-completion-0a17b0e62fbb76a1.yaml diff --git a/pytest_httpserver/httpserver.py b/pytest_httpserver/httpserver.py index 5513c0b8..c28a4e0a 100644 --- a/pytest_httpserver/httpserver.py +++ b/pytest_httpserver/httpserver.py @@ -1073,6 +1073,8 @@ def __init__( self.default_waiting_settings = WaitingSettings() self._waiting_settings = copy(self.default_waiting_settings) self._waiting_result: queue.LifoQueue[bool] = queue.LifoQueue(maxsize=1) + self._handler_lock = threading.RLock() + self._active_waiting_handlers = 0 self.startup_timeout = startup_timeout self._readiness_check_pending = False @@ -1422,27 +1424,31 @@ def dispatch(self, request: Request) -> Response: if self.permanently_failed: return self.respond_permanent_failure() - handler = None - if self.ordered_handlers: - handler = self.ordered_handlers[0] - if not handler.matcher.match(request): - self.permanently_failed = True - response = self.respond_nohandler(request) - return response - - self.ordered_handlers.pop(0) - self._update_waiting_result() - - if not handler: - handler = self.oneshot_handlers.match(request) - if handler: - self.oneshot_handlers.remove(handler) - self._update_waiting_result() - else: - handler = self.handlers.match(request) + with self._handler_lock: + handler = None + consumed_handler = False + if self.ordered_handlers: + handler = self.ordered_handlers[0] + if not handler.matcher.match(request): + self.permanently_failed = True + response = self.respond_nohandler(request) + return response + + self.ordered_handlers.pop(0) + consumed_handler = True if not handler: - return self.respond_nohandler(request) + handler = self.oneshot_handlers.match(request) + if handler: + self.oneshot_handlers.remove(handler) + consumed_handler = True + else: + handler = self.handlers.match(request) + + if not handler: + return self.respond_nohandler(request) + if consumed_handler: + self._active_waiting_handlers += 1 try: response = handler.respond(request) @@ -1455,6 +1461,11 @@ def dispatch(self, request: Request) -> Response: except Exception as e: self.handler_errors.append(e) raise + finally: + if consumed_handler: + with self._handler_lock: + self._active_waiting_handlers -= 1 + self._update_waiting_result() if response is None: response = Response("") @@ -1473,7 +1484,7 @@ def _set_waiting_result(self, value: bool) -> None: # noqa: FBT001 self._waiting_result.put_nowait(value) def _update_waiting_result(self) -> None: - if not self.oneshot_handlers and not self.ordered_handlers: + if not self.oneshot_handlers and not self.ordered_handlers and not self._active_waiting_handlers: self._set_waiting_result(value=True) @contextmanager diff --git a/releasenotes/notes/wait-handler-completion-0a17b0e62fbb76a1.yaml b/releasenotes/notes/wait-handler-completion-0a17b0e62fbb76a1.yaml new file mode 100644 index 00000000..4b9f605e --- /dev/null +++ b/releasenotes/notes/wait-handler-completion-0a17b0e62fbb76a1.yaml @@ -0,0 +1,5 @@ +fixes: + - | + Wait for ordered and oneshot response callbacks to finish before reporting + successful completion from ``HTTPServer.wait()``, including callbacks + executing concurrently in a threaded server. diff --git a/tests/test_wait.py b/tests/test_wait.py index d23f65de..05ee8495 100644 --- a/tests/test_wait.py +++ b/tests/test_wait.py @@ -1,8 +1,13 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Event + import requests from pytest import approx +from pytest import mark from pytest import raises - from pytest_httpserver import HTTPServer +from werkzeug import Request +from werkzeug import Response def test_wait_success(httpserver: HTTPServer): @@ -70,3 +75,59 @@ def test_wait_raise_assertion_false(httpserver: HTTPServer): assert not waiting.result waiting_time_error = 0.1 assert waiting.elapsed_time == approx(waiting_timeout, abs=waiting_time_error) + + +@mark.parametrize("ordered", [False, True]) +def test_wait_does_not_finish_while_last_handler_is_running(*, ordered: bool): + server = HTTPServer() + entered = Event() + release = Event() + + def respond(_request: Request) -> Response: + entered.set() + assert release.wait(5) + return Response("finished") + + handler = server.expect_ordered_request("/") if ordered else server.expect_oneshot_request("/") + handler.respond_with_handler(respond) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + with server.wait(timeout=0.01, raise_assertions=False) as waiting: + future = executor.submit(server.dispatch, Request.from_values(path="/")) + assert entered.wait(5) + assert not waiting.result + finally: + release.set() + assert future.result(timeout=5).data == b"finished" + + +@mark.parametrize("ordered", [False, True]) +def test_wait_tracks_other_callbacks_after_handler_lists_are_empty(*, ordered: bool): + server = HTTPServer(threaded=True) + entered = [Event(), Event()] + release = [Event(), Event()] + + def respond(request: Request) -> Response: + index = int(request.path[1:]) + entered[index].set() + assert release[index].wait(5) + return Response("finished") + + for index in range(2): + handler = server.expect_ordered_request(f"/{index}") if ordered else server.expect_oneshot_request(f"/{index}") + handler.respond_with_handler(respond) + + with ThreadPoolExecutor(max_workers=2) as executor: + try: + with server.wait(timeout=0.01, raise_assertions=False) as waiting: + first = executor.submit(server.dispatch, Request.from_values(path="/0")) + assert entered[0].wait(5) + second = executor.submit(server.dispatch, Request.from_values(path="/1")) + assert entered[1].wait(5) + release[1].set() + assert second.result(timeout=5).data == b"finished" + assert not waiting.result + finally: + release[0].set() + release[1].set() + assert first.result(timeout=5).data == b"finished" From fcf675bb31a31ecb5ed7242d845cd69d58d135f7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:30:15 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_wait.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_wait.py b/tests/test_wait.py index 05ee8495..82c96a0c 100644 --- a/tests/test_wait.py +++ b/tests/test_wait.py @@ -5,10 +5,11 @@ from pytest import approx from pytest import mark from pytest import raises -from pytest_httpserver import HTTPServer from werkzeug import Request from werkzeug import Response +from pytest_httpserver import HTTPServer + def test_wait_success(httpserver: HTTPServer): waiting_timeout = 0.1 From ce8f4a82a017c13b89226a3078e3c3dc7c31787f Mon Sep 17 00:00:00 2001 From: Shahadat Islam Koyel <179203101+ShahadatTest@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:18:38 +0600 Subject: [PATCH 3/3] Exercise waiting regressions through real HTTP connections --- tests/test_wait.py | 57 +++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/tests/test_wait.py b/tests/test_wait.py index 82c96a0c..145a287c 100644 --- a/tests/test_wait.py +++ b/tests/test_wait.py @@ -1,4 +1,5 @@ -from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from http.client import HTTPConnection from threading import Event import requests @@ -79,8 +80,7 @@ def test_wait_raise_assertion_false(httpserver: HTTPServer): @mark.parametrize("ordered", [False, True]) -def test_wait_does_not_finish_while_last_handler_is_running(*, ordered: bool): - server = HTTPServer() +def test_wait_does_not_finish_while_last_handler_is_running(httpserver: HTTPServer, *, ordered: bool): entered = Event() release = Event() @@ -89,22 +89,21 @@ def respond(_request: Request) -> Response: assert release.wait(5) return Response("finished") - handler = server.expect_ordered_request("/") if ordered else server.expect_oneshot_request("/") + handler = httpserver.expect_ordered_request("/") if ordered else httpserver.expect_oneshot_request("/") handler.respond_with_handler(respond) - with ThreadPoolExecutor(max_workers=1) as executor: + with closing(HTTPConnection(httpserver.host, httpserver.port, timeout=5)) as connection: try: - with server.wait(timeout=0.01, raise_assertions=False) as waiting: - future = executor.submit(server.dispatch, Request.from_values(path="/")) + with httpserver.wait(timeout=0.01, raise_assertions=False) as waiting: + connection.request("GET", "/") assert entered.wait(5) assert not waiting.result finally: release.set() - assert future.result(timeout=5).data == b"finished" + assert connection.getresponse().read() == b"finished" @mark.parametrize("ordered", [False, True]) def test_wait_tracks_other_callbacks_after_handler_lists_are_empty(*, ordered: bool): - server = HTTPServer(threaded=True) entered = [Event(), Event()] release = [Event(), Event()] @@ -114,21 +113,27 @@ def respond(request: Request) -> Response: assert release[index].wait(5) return Response("finished") - for index in range(2): - handler = server.expect_ordered_request(f"/{index}") if ordered else server.expect_oneshot_request(f"/{index}") - handler.respond_with_handler(respond) - - with ThreadPoolExecutor(max_workers=2) as executor: - try: - with server.wait(timeout=0.01, raise_assertions=False) as waiting: - first = executor.submit(server.dispatch, Request.from_values(path="/0")) - assert entered[0].wait(5) - second = executor.submit(server.dispatch, Request.from_values(path="/1")) - assert entered[1].wait(5) + with HTTPServer(threaded=True) as server: + for index in range(2): + handler = ( + server.expect_ordered_request(f"/{index}") if ordered else server.expect_oneshot_request(f"/{index}") + ) + handler.respond_with_handler(respond) + + with ( + closing(HTTPConnection(server.host, server.port, timeout=5)) as first, + closing(HTTPConnection(server.host, server.port, timeout=5)) as second, + ): + try: + with server.wait(timeout=0.01, raise_assertions=False) as waiting: + first.request("GET", "/0") + assert entered[0].wait(5) + second.request("GET", "/1") + assert entered[1].wait(5) + release[1].set() + assert second.getresponse().read() == b"finished" + assert not waiting.result + finally: + release[0].set() release[1].set() - assert second.result(timeout=5).data == b"finished" - assert not waiting.result - finally: - release[0].set() - release[1].set() - assert first.result(timeout=5).data == b"finished" + assert first.getresponse().read() == b"finished"