Skip to content

Commit a28a458

Browse files
authored
Merge pull request #3824 from joschrag/fix/quart-threadedrunner-stop-graceful-shutdown
Bugfix: Fix quart threadedrunner stop graceful shutdown
2 parents 4699277 + 645d2a6 commit a28a458

3 files changed

Lines changed: 94 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1010
## Fixed
1111
- [#3822](https://github.com/plotly/dash/pull/3822) Fix `UnboundLocalError` for `user_callback_output` in async background callbacks (Celery and Diskcache managers) when the callback raises `PreventUpdate` or another exception before the variable is assigned.
1212
- [#3819](https://github.com/plotly/dash/pull/3819) Fix `RuntimeError: No active request in context` when a non-Dash path falls through to the FastAPI catch-all route. Fixes [#3812](https://github.com/plotly/dash/issues/3812).
13-
- [#3838](https://github.com/plotly/dash/pull/3838) Replace `mcp` dependency with inline types
13+
- [#3838](https://github.com/plotly/dash/pull/3838) Replace `mcp` dependency with inline types.
14+
- [#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).
1415

1516
## Changed
1617
- Drop support for Python 3.8 (end-of-life since October 2024). The minimum supported version is now Python 3.9.

dash/testing/application_runners.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,27 @@ def run():
218218
raise DashAppLoadingError("threaded server failed to start")
219219

220220
def stop(self):
221+
# pylint: disable=protected-access
222+
server_type = getattr(
223+
getattr(self._app, "backend", None), "server_type", "flask"
224+
)
221225
# For FastAPI apps with uvicorn, use graceful shutdown
222-
if self._app and hasattr(self._app, "_uvicorn_server"):
223-
server = self._app._uvicorn_server # pylint: disable=protected-access
226+
if server_type == "fastapi":
227+
server = self._app._uvicorn_server # type: ignore[reportOptionalMemberAccess]
224228
server.should_exit = True
225229
self.thread.join(timeout=self.stop_timeout) # type: ignore[reportOptionalMemberAccess]
230+
# For Quart apps, signal hypercorn's cooperative shutdown event. Only the
231+
# main-thread signal handler sets it, but in tests the server runs in a
232+
# worker thread, so we set it ourselves -- thread-safely, on the server's
233+
# own loop (the event binds its loop on first await) -- then join bounded.
234+
elif server_type == "quart":
235+
quart_shutdown_event = getattr(
236+
getattr(self._app, "backend", None), "_ws_shutdown_event", None
237+
)
238+
loop = getattr(quart_shutdown_event, "_loop", None)
239+
if loop is not None and not loop.is_closed():
240+
loop.call_soon_threadsafe(quart_shutdown_event.set) # type: ignore[reportOptionalMemberAccess]
241+
self.thread.join(timeout=self.stop_timeout) # type: ignore[reportOptionalMemberAccess]
226242
else:
227243
# Fall back to killing threads for Flask/other backends
228244
self.thread.kill() # type: ignore[reportOptionalMemberAccess]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import threading
2+
import time
3+
4+
import pytest
5+
6+
from dash import Dash, Input, Output, dcc, html
7+
from dash.testing.application_runners import ThreadedRunner
8+
9+
10+
@pytest.mark.parametrize("backend", ["flask", "quart", "fastapi"])
11+
def test_threaded_runner_stop_is_bounded(backend):
12+
"""Regression test: ``ThreadedRunner.stop()`` must return within the timeout
13+
for every backend instead of wedging the suite -- both the graceful ASGI
14+
path (quart/fastapi) and Flask's ``thread.kill()`` path.
15+
"""
16+
17+
if backend == "quart":
18+
pytest.importorskip(
19+
"quart", reason="Quart extra dependencies are not installed"
20+
)
21+
pytest.importorskip("hypercorn", reason="hypercorn is not installed")
22+
elif backend == "fastapi":
23+
pytest.importorskip(
24+
"fastapi", reason="fastapi extra dependencies are not installed"
25+
)
26+
27+
app = Dash(__name__, backend=backend)
28+
app.layout = html.Div(
29+
[dcc.Input(id="input", value="initial value"), html.Div(id="output")]
30+
)
31+
32+
@app.callback(Output("output", "children"), Input("input", "value"))
33+
def update_output(value):
34+
return value
35+
36+
runner = ThreadedRunner(stop_timeout=3)
37+
runner.host = "127.0.0.1"
38+
39+
# Run stop() in a watchdog so a regression fails the assertion instead of
40+
# hanging the suite. The watchdog is started BEFORE runner.start(): Flask's
41+
# stop() -> thread.kill() injects SystemExit into every thread created after
42+
# the KillerThread, so a watchdog spawned afterwards would kill itself.
43+
# Starting it first lands it in KillerThread._old_threads, which kill() skips
44+
# -- harmless for the graceful backends, which never call kill().
45+
done = threading.Event()
46+
go = threading.Event()
47+
48+
def _stop():
49+
go.wait()
50+
runner.stop()
51+
done.set()
52+
53+
threading.Thread(target=_stop, daemon=True).start()
54+
runner.start(app, host="127.0.0.1")
55+
56+
try:
57+
start = time.monotonic()
58+
go.set()
59+
returned = done.wait(timeout=runner.stop_timeout + 5)
60+
elapsed = time.monotonic() - start
61+
62+
assert returned, (
63+
f"ThreadedRunner.stop() did not return for a {backend} app within "
64+
f"{runner.stop_timeout + 5}s -- regression of the teardown hang"
65+
)
66+
assert elapsed < runner.stop_timeout + 2
67+
assert not runner.thread.is_alive()
68+
assert runner.started is False
69+
finally:
70+
if runner.started:
71+
try:
72+
runner.stop()
73+
except Exception: # pylint: disable=broad-except
74+
pass

0 commit comments

Comments
 (0)