Skip to content

[BUG] dash.testing ThreadedRunner.stop() hangs at teardown for Quart backend apps #3823

@joschrag

Description

@joschrag

Describe your context
Please provide us your environment, so we can easily reproduce the issue.

  • replace the result of pip list | grep dash below
dash                      4.2.0
dash-testing-stub         0.0.2
  • Python 3.12 (Windows 10)
  • Python 3.14 (Windows 10, Ubuntu).

Describe the bug

When a Dash app uses the Quart backend (Dash(__name__, backend="quart")) and is served through dash.testing's ThreadedRunner (the default integration-test runner, server in a worker thread), ThreadedRunner.stop() can hang at teardown — the test passes but the runner never returns.

stop() only has a graceful-shutdown branch for FastAPI, keyed on hasattr(self._app, "_uvicorn_server") (dash/testing/application_runners.py). A Quart app has no such attribute, so it falls into the else branch, which does:

The hypercorn server thread is parked in a blocking syscall (IOCP on Windows, epoll on POSIX), so the injected SystemExit isn't delivered until the loop next runs Python bytecode. The unbounded join() then blocks. Meanwhile the Quart backend's own cooperative shutdown switch — backend._ws_shutdown_event, awaited by serve(shutdown_trigger=...) — is never signalled, because only the main-thread signal handler sets it and the server is running in a worker thread.

Minimal reproduction:

import threading
from dash import Dash, Input, Output, dcc, html
from dash.testing.application_runners import ThreadedRunner

app = Dash(__name__, backend="quart")
app.layout = html.Div([dcc.Input(id="in", value="x"), html.Div(id="out")])
app.callback(Output("out", "children"), Input("in", "value"))(lambda v: v)

runner = ThreadedRunner(stop_timeout=3)
runner.start(app, host="127.0.0.1")

done = threading.Event() # flag; set only if stop() actually returns

# run stop() on a daemon thread so a hang wedges that thread, not the script
threading.Thread(target=lambda: (runner.stop(), done.set()), daemon=True).start()

# wait up to 10s (> stop_timeout); False means stop() never returned = the hang
print("stop() returned" if done.wait(10) else "HANG: stop() did not return in 10s")

Output: HANG: stop() did not return in 10s (FastAPI or Flask print stop() returned).

Expected behavior

ThreadedRunner.stop() should shut a Quart app down cleanly and return within stop_timeout, the same as it does for FastAPI/Flask.

Screenshots

If applicable, add screenshots or screen recording to help explain your problem.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions