From ca3561d36b5e59ced9e775ca4f54bd84d6a84979 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:10:17 +0200 Subject: [PATCH 1/3] Fix Quart ThreadedRunner.stop() teardown hang ThreadedRunner.stop() only had a graceful shutdown branch for FastAPI (keyed on `_uvicorn_server`). A Quart app fell through to the kill-based branch, which injects an async SystemExit via thread.kill() and then calls join() with no timeout. The server thread is parked in a blocking syscall (IOCP on Windows, epoll on POSIX), so the SystemExit is not delivered promptly and join() can hang indefinitely -- on Windows and Linux alike. Add a Quart branch that signals the backend's existing cooperative shutdown switch (backend._ws_shutdown_event) thread-safely on the server's own loop via call_soon_threadsafe, then joins bounded by stop_timeout. Flask/other backends keep the kill path unchanged. --- dash/testing/application_runners.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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] From bab120416ed833bb252ef4e0108f270eaf7fcce2 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:10:19 +0200 Subject: [PATCH 2/3] Add regression test + changelog for Quart stop() hang Add a dash.testing regression test that starts a Quart app on ThreadedRunner and asserts stop() returns bounded by stop_timeout (run under a watchdog so a regression fails fast instead of wedging the suite). Verified it fails against the unpatched stop() and passes with the fix. --- CHANGELOG.md | 1 + .../test_threaded_runner_stop.py | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/backend_tests/test_threaded_runner_stop.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e18cb41c78..dc6e991afa 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 +- Fix `dash.testing` `ThreadedRunner.stop()` hanging at teardown for Quart apps. The graceful-shutdown branch was FastAPI-only, so a Quart app fell through to a thread kill followed by an unbounded `join()` that could block forever (the server thread is parked in a blocking syscall — IOCP on Windows, epoll on POSIX — so the injected `SystemExit` is not delivered promptly). `stop()` now signals the Quart backend's cooperative shutdown event on the server's own loop and joins bounded by `stop_timeout`. ## [4.2.0] - 2026-06-01 - *The Freedom Update* 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 From 76829ec47a46d026facc00ff2b874bc847736e65 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:15:52 +0200 Subject: [PATCH 3/3] Add PR and issue numbers to changelog entry. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc6e991afa..2f4e9666f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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 -- Fix `dash.testing` `ThreadedRunner.stop()` hanging at teardown for Quart apps. The graceful-shutdown branch was FastAPI-only, so a Quart app fell through to a thread kill followed by an unbounded `join()` that could block forever (the server thread is parked in a blocking syscall — IOCP on Windows, epoll on POSIX — so the injected `SystemExit` is not delivered promptly). `stop()` now signals the Quart backend's cooperative shutdown event on the server's own loop and joins bounded by `stop_timeout`. +- [#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*