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/5] 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/5] 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/5] 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* From c4082ff3ed37d1c1cadec25d33385c984cd444c0 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:59:02 +0200 Subject: [PATCH 4/5] Drop private variables check and make distinction via backend attr. --- dash/testing/application_runners.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 44eb32b2b6..b318d2419d 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -219,22 +219,25 @@ def run(): def stop(self): # pylint: disable=protected-access - quart_shutdown_event = getattr( - getattr(self._app, "backend", None), "_ws_shutdown_event", None + 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 + 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 quart_shutdown_event is not None: + 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) + 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 From e7b11a4ad64de30d582b2f63466f81208906540d Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:26:45 +0200 Subject: [PATCH 5/5] Parametrize tests to test threaded runner shutdown against all backends. --- .../test_threaded_runner_stop.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/backend_tests/test_threaded_runner_stop.py b/tests/backend_tests/test_threaded_runner_stop.py index 688164ba42..a2d5f70975 100644 --- a/tests/backend_tests/test_threaded_runner_stop.py +++ b/tests/backend_tests/test_threaded_runner_stop.py @@ -7,23 +7,24 @@ 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.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. """ - pytest.importorskip("quart", reason="Quart extra dependencies are not installed") - pytest.importorskip("hypercorn", reason="hypercorn is not installed") - app = Dash(__name__, backend="quart") + 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")] ) @@ -34,32 +35,32 @@ def update_output(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() 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() - # 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(): + go.wait() + runner.stop() + done.set() - def _stop(): - runner.stop() - done.set() + threading.Thread(target=_stop, daemon=True).start() + runner.start(app, host="127.0.0.1") + try: start = time.monotonic() - threading.Thread(target=_stop, daemon=True).start() + go.set() 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"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