Skip to content

Commit bab1204

Browse files
committed
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.
1 parent ca3561d commit bab1204

2 files changed

Lines changed: 74 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
77
### Fixed
88
- [#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).
99
- [#3813](https://github.com/plotly/dash/pull/3813) Fix websockets using incorrect path when deployed behind a proxy
10+
- 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`.
1011

1112
## [4.2.0] - 2026-06-01 - *The Freedom Update*
1213

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
def test_quart_threaded_runner_stop_is_graceful_and_bounded():
11+
"""Regression test: ``ThreadedRunner.stop()`` must not hang for a Quart app.
12+
13+
``stop()`` only had a graceful-shutdown branch for FastAPI (keyed on
14+
``_uvicorn_server``). A Quart app fell through to ``thread.kill()`` followed
15+
by an unbounded ``thread.join()``. The server thread is parked in a blocking
16+
syscall (IOCP on Windows, epoll on POSIX), so the injected ``SystemExit`` is
17+
not delivered promptly and ``join()`` can block forever.
18+
19+
``stop()`` now signals the Quart backend's cooperative shutdown event
20+
(``backend._ws_shutdown_event``) on the server's own loop and joins bounded
21+
by ``stop_timeout``.
22+
"""
23+
pytest.importorskip("quart", reason="Quart extra dependencies are not installed")
24+
pytest.importorskip("hypercorn", reason="hypercorn is not installed")
25+
26+
app = Dash(__name__, backend="quart")
27+
app.layout = html.Div(
28+
[dcc.Input(id="input", value="initial value"), html.Div(id="output")]
29+
)
30+
31+
@app.callback(Output("output", "children"), Input("input", "value"))
32+
def update_output(value):
33+
return value
34+
35+
runner = ThreadedRunner(stop_timeout=3)
36+
runner.host = "127.0.0.1"
37+
runner.start(app, host="127.0.0.1")
38+
39+
try:
40+
# Sanity: a Quart app does NOT take the FastAPI graceful branch ...
41+
assert not hasattr(app, "_uvicorn_server")
42+
# ... but its backend does expose the cooperative shutdown switch.
43+
assert getattr(app.backend, "_ws_shutdown_event", None) is not None
44+
45+
# Run stop() under a watchdog so a regression fails fast instead of
46+
# wedging the whole suite. The graceful path never calls thread.kill(),
47+
# so this watchdog thread is safe; a regression to the kill path would
48+
# inject SystemExit here and leave `done` unset -> the assertion below
49+
# fails (bounded) rather than hanging forever.
50+
done = threading.Event()
51+
52+
def _stop():
53+
runner.stop()
54+
done.set()
55+
56+
start = time.monotonic()
57+
threading.Thread(target=_stop, daemon=True).start()
58+
returned = done.wait(timeout=runner.stop_timeout + 5)
59+
elapsed = time.monotonic() - start
60+
61+
assert returned, (
62+
"ThreadedRunner.stop() did not return for a Quart app within "
63+
f"{runner.stop_timeout + 5}s -- regression of the teardown hang"
64+
)
65+
assert elapsed < runner.stop_timeout + 2
66+
assert not runner.thread.is_alive()
67+
assert runner.started is False
68+
finally:
69+
if runner.started:
70+
try:
71+
runner.stop()
72+
except Exception: # pylint: disable=broad-except
73+
pass

0 commit comments

Comments
 (0)