diff --git a/pytest_httpserver/httpserver.py b/pytest_httpserver/httpserver.py index 5513c0b..c28a4e0 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 0000000..4b9f605 --- /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 d23f65d..145a287 100644 --- a/tests/test_wait.py +++ b/tests/test_wait.py @@ -1,6 +1,13 @@ +from contextlib import closing +from http.client import HTTPConnection +from threading import Event + import requests from pytest import approx +from pytest import mark from pytest import raises +from werkzeug import Request +from werkzeug import Response from pytest_httpserver import HTTPServer @@ -70,3 +77,63 @@ 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(httpserver: HTTPServer, *, ordered: bool): + entered = Event() + release = Event() + + def respond(_request: Request) -> Response: + entered.set() + assert release.wait(5) + return Response("finished") + + handler = httpserver.expect_ordered_request("/") if ordered else httpserver.expect_oneshot_request("/") + handler.respond_with_handler(respond) + with closing(HTTPConnection(httpserver.host, httpserver.port, timeout=5)) as connection: + try: + 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 connection.getresponse().read() == b"finished" + + +@mark.parametrize("ordered", [False, True]) +def test_wait_tracks_other_callbacks_after_handler_lists_are_empty(*, ordered: bool): + 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") + + 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 first.getresponse().read() == b"finished"