diff --git a/CHANGELOG.md b/CHANGELOG.md index e18cb41c78..2f4e9666f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Fixed - [#3805](https://github.com/plotly/dash/pull/3805) Fix FastAPI POST routes deadlock caused by middleware consuming request body. Fixes [#3801](https://github.com/plotly/dash/issues/3801). - [#3813](https://github.com/plotly/dash/pull/3813) Fix websockets using incorrect path when deployed behind a proxy +- [#3824](https://github.com/plotly/dash/pull/3824) Fix `dash.testing` `ThreadedRunner.stop()` hanging at teardown for Quart apps. Fixes [#3823](https://github.com/plotly/dash/issues/3823). ## [4.2.0] - 2026-06-01 - *The Freedom Update* diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 51a938f72f..b318d2419d 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -218,11 +218,27 @@ def run(): raise DashAppLoadingError("threaded server failed to start") def stop(self): + # pylint: disable=protected-access + server_type = getattr( + getattr(self._app, "backend", None), "server_type", "flask" + ) # For FastAPI apps with uvicorn, use graceful shutdown - if self._app and hasattr(self._app, "_uvicorn_server"): - server = self._app._uvicorn_server # pylint: disable=protected-access + if server_type == "fastapi": + server = self._app._uvicorn_server # type: ignore[reportOptionalMemberAccess] server.should_exit = True self.thread.join(timeout=self.stop_timeout) # type: ignore[reportOptionalMemberAccess] + # For Quart apps, signal hypercorn's cooperative shutdown event. Only the + # main-thread signal handler sets it, but in tests the server runs in a + # worker thread, so we set it ourselves -- thread-safely, on the server's + # own loop (the event binds its loop on first await) -- then join bounded. + elif server_type == "quart": + quart_shutdown_event = getattr( + getattr(self._app, "backend", None), "_ws_shutdown_event", None + ) + loop = getattr(quart_shutdown_event, "_loop", None) + if loop is not None and not loop.is_closed(): + loop.call_soon_threadsafe(quart_shutdown_event.set) # type: ignore[reportOptionalMemberAccess] + self.thread.join(timeout=self.stop_timeout) # type: ignore[reportOptionalMemberAccess] else: # Fall back to killing threads for Flask/other backends self.thread.kill() # type: ignore[reportOptionalMemberAccess] diff --git a/tests/backend_tests/test_threaded_runner_stop.py b/tests/backend_tests/test_threaded_runner_stop.py new file mode 100644 index 0000000000..a2d5f70975 --- /dev/null +++ b/tests/backend_tests/test_threaded_runner_stop.py @@ -0,0 +1,74 @@ +import threading +import time + +import pytest + +from dash import Dash, Input, Output, dcc, html +from dash.testing.application_runners import ThreadedRunner + + +@pytest.mark.parametrize("backend", ["flask", "quart", "fastapi"]) +def test_threaded_runner_stop_is_bounded(backend): + """Regression test: ``ThreadedRunner.stop()`` must return within the timeout + for every backend instead of wedging the suite -- both the graceful ASGI + path (quart/fastapi) and Flask's ``thread.kill()`` path. + """ + + if backend == "quart": + pytest.importorskip( + "quart", reason="Quart extra dependencies are not installed" + ) + pytest.importorskip("hypercorn", reason="hypercorn is not installed") + elif backend == "fastapi": + pytest.importorskip( + "fastapi", reason="fastapi extra dependencies are not installed" + ) + + app = Dash(__name__, backend=backend) + app.layout = html.Div( + [dcc.Input(id="input", value="initial value"), html.Div(id="output")] + ) + + @app.callback(Output("output", "children"), Input("input", "value")) + def update_output(value): + return value + + runner = ThreadedRunner(stop_timeout=3) + runner.host = "127.0.0.1" + + # Run stop() in a watchdog so a regression fails the assertion instead of + # hanging the suite. The watchdog is started BEFORE runner.start(): Flask's + # stop() -> thread.kill() injects SystemExit into every thread created after + # the KillerThread, so a watchdog spawned afterwards would kill itself. + # Starting it first lands it in KillerThread._old_threads, which kill() skips + # -- harmless for the graceful backends, which never call kill(). + done = threading.Event() + go = threading.Event() + + def _stop(): + go.wait() + runner.stop() + done.set() + + threading.Thread(target=_stop, daemon=True).start() + runner.start(app, host="127.0.0.1") + + try: + start = time.monotonic() + go.set() + returned = done.wait(timeout=runner.stop_timeout + 5) + elapsed = time.monotonic() - start + + assert returned, ( + f"ThreadedRunner.stop() did not return for a {backend} app within " + f"{runner.stop_timeout + 5}s -- regression of the teardown hang" + ) + assert elapsed < runner.stop_timeout + 2 + assert not runner.thread.is_alive() + assert runner.started is False + finally: + if runner.started: + try: + runner.stop() + except Exception: # pylint: disable=broad-except + pass