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..44eb32b2b6 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -218,11 +218,24 @@ def run(): raise DashAppLoadingError("threaded server failed to start") def stop(self): + # pylint: disable=protected-access + quart_shutdown_event = getattr( + getattr(self._app, "backend", None), "_ws_shutdown_event", None + ) # 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 + server = self._app._uvicorn_server 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 quart_shutdown_event is not 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) + 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..688164ba42 --- /dev/null +++ b/tests/backend_tests/test_threaded_runner_stop.py @@ -0,0 +1,73 @@ +import threading +import time + +import pytest + +from dash import Dash, Input, Output, dcc, html +from dash.testing.application_runners import ThreadedRunner + + +def test_quart_threaded_runner_stop_is_graceful_and_bounded(): + """Regression test: ``ThreadedRunner.stop()`` must not hang for a Quart app. + + ``stop()`` only had a graceful-shutdown branch for FastAPI (keyed on + ``_uvicorn_server``). A Quart app fell through to ``thread.kill()`` followed + by an unbounded ``thread.join()``. The server thread is parked in a blocking + syscall (IOCP on Windows, epoll on POSIX), so the injected ``SystemExit`` is + not delivered promptly and ``join()`` can block forever. + + ``stop()`` now signals the Quart backend's cooperative shutdown event + (``backend._ws_shutdown_event``) on the server's own loop and joins bounded + by ``stop_timeout``. + """ + pytest.importorskip("quart", reason="Quart extra dependencies are not installed") + pytest.importorskip("hypercorn", reason="hypercorn is not installed") + + app = Dash(__name__, backend="quart") + 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" + runner.start(app, host="127.0.0.1") + + try: + # Sanity: a Quart app does NOT take the FastAPI graceful branch ... + assert not hasattr(app, "_uvicorn_server") + # ... but its backend does expose the cooperative shutdown switch. + assert getattr(app.backend, "_ws_shutdown_event", None) is not None + + # Run stop() under a watchdog so a regression fails fast instead of + # wedging the whole suite. The graceful path never calls thread.kill(), + # so this watchdog thread is safe; a regression to the kill path would + # inject SystemExit here and leave `done` unset -> the assertion below + # fails (bounded) rather than hanging forever. + done = threading.Event() + + def _stop(): + runner.stop() + done.set() + + start = time.monotonic() + threading.Thread(target=_stop, daemon=True).start() + returned = done.wait(timeout=runner.stop_timeout + 5) + elapsed = time.monotonic() - start + + assert returned, ( + "ThreadedRunner.stop() did not return for a Quart 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