Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*

Expand Down
15 changes: 14 additions & 1 deletion dash/testing/application_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
73 changes: 73 additions & 0 deletions tests/backend_tests/test_threaded_runner_stop.py
Original file line number Diff line number Diff line change
@@ -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