Skip to content

Commit 84551e5

Browse files
committed
Forbid sync-generator streaming callbacks
Sync generator streaming is too limited/buggy (it occupies a server worker for the whole stream), so reject it at registration with a StreamCallbackError instead of warning. Async generators are now the only supported streaming path; remove the dead sync wrapper machinery. Fix a pre-existing bug on that async path: _astream_frames held one callback-context token across all yields, but the keepalive driver resumes each __anext__ in a freshly copied context (via ensure_future), so the reset ran in a different context and raised. Set/reset the context var around each generator step (and the on_error handler) instead. With stream_keepalive_interval enabled by default, this broke every async streaming callback that used dash.ctx/set_props.
1 parent 3fd3ed7 commit 84551e5

5 files changed

Lines changed: 81 additions & 164 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
55
## [Unreleased]
66

77
### Added
8-
- [#3888](https://github.com/plotly/dash/pull/3888/) Streaming callbacks: a callback defined as a generator (or async generator) function streams — its yields are pushed to the browser as they are produced, no keyword needed. Each yielded value has the same shape as a regular return value and replaces the outputs; yielding `dash.Patch` objects gives incremental updates (e.g. LLM token streaming). Streams ride the WebSocket callback transport when active, otherwise the HTTP response streams NDJSON frames. Works on Flask, Quart, and FastAPI; synchronous generators warn at registration since they occupy a server worker for the whole stream. HTTP streams emit a blank keepalive line every `stream_keepalive_interval` milliseconds (default 15000) that the callback spends between yields, so proxy idle timeouts (nginx's `proxy_read_timeout` defaults to 60s) don't close a stream while the callback is still working; set `stream_keepalive_interval=None` on the app to disable.
8+
- [#3888](https://github.com/plotly/dash/pull/3888/) Streaming callbacks: a callback defined as a generator (or async generator) function streams — its yields are pushed to the browser as they are produced, no keyword needed. Each yielded value has the same shape as a regular return value and replaces the outputs; yielding `dash.Patch` objects gives incremental updates (e.g. LLM token streaming). Streams ride the WebSocket callback transport when active, otherwise the HTTP response streams NDJSON frames. Works on Flask, Quart, and FastAPI; the callback must be an `async def` generator — synchronous generators are rejected at registration since they occupy a server worker for the whole stream. HTTP streams emit a blank keepalive line every `stream_keepalive_interval` milliseconds (default 15000) that the callback spends between yields, so proxy idle timeouts (nginx's `proxy_read_timeout` defaults to 60s) don't close a stream while the callback is still working; set `stream_keepalive_interval=None` on the app to disable.
99
- [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release.
1010

1111
### Removed

dash/_callback.py

Lines changed: 42 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import inspect
44
import logging
55
import warnings
6-
from contextvars import copy_context
76
from functools import wraps
87
from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast
98

@@ -115,13 +114,14 @@ def callback(
115114
not to fire when its outputs are first added to the page. Defaults to
116115
`False` and unlike `app.callback` is not configurable at the app level.
117116
118-
Decorating a generator function (or async generator function) registers a
119-
streaming callback: each yielded value has the same shape as a regular
117+
Decorating an async generator function (`async def` with `yield`) registers
118+
a streaming callback: each yielded value has the same shape as a regular
120119
return value (one value per `Output`) and is pushed to the browser
121120
immediately; yield `dash.Patch` objects for incremental updates. Streams
122121
over the WebSocket callback transport when active, otherwise over the HTTP
123-
response (NDJSON). Streaming callbacks cannot be combined with
124-
`background=True`, `mcp_enabled=True` or `api_endpoint`.
122+
response (NDJSON). Synchronous generators are not supported (they would
123+
occupy a server worker for the whole stream). Streaming callbacks cannot be
124+
combined with `background=True`, `mcp_enabled=True` or `api_endpoint`.
125125
126126
:Keyword Arguments:
127127
:param background:
@@ -270,8 +270,15 @@ def callback(
270270
)
271271

272272

273-
def _validate_stream_callback(callback_id, background, kwargs):
273+
def _validate_stream_callback(callback_id, background, kwargs, is_sync_gen):
274274
"""Reject options a streaming (generator) callback cannot be combined with."""
275+
if is_sync_gen:
276+
raise StreamCallbackError(
277+
f"Streaming callback '{callback_id}' is a synchronous generator, "
278+
"which is not supported: a sync generator occupies a server worker "
279+
"for the whole stream. Define it with 'async def' so it streams on "
280+
"the event loop instead."
281+
)
275282
if background is not None:
276283
raise BackgroundCallbackError(
277284
f"Streaming callback '{callback_id}' cannot be combined with "
@@ -819,7 +826,9 @@ def wrap_func(func):
819826
is_async_gen_func = inspect.isasyncgenfunction(func)
820827
is_stream = is_gen_func or is_async_gen_func
821828
if is_stream:
822-
_validate_stream_callback(callback_id, background, _kwargs)
829+
_validate_stream_callback(
830+
callback_id, background, _kwargs, is_sync_gen=is_gen_func
831+
)
823832

824833
if _kwargs.get("api_endpoint"):
825834
api_endpoint = _kwargs.get("api_endpoint")
@@ -1018,57 +1027,35 @@ def _stream_error_frame(err):
10181027
logger.exception("Exception raised in streamed callback")
10191028
return {"done": True, "error": {"message": str(err) or repr(err)}}
10201029

1021-
def _stream_frames(
1022-
user_gen, error_handler, output_spec, callback_ctx, app, original_packages
1023-
):
1024-
try:
1025-
while True:
1026-
frame = None
1027-
try:
1028-
output_value = next(user_gen)
1029-
frame = _build_stream_frame(
1030-
output_value,
1031-
output_spec,
1032-
callback_ctx,
1033-
app,
1034-
original_packages,
1035-
)
1036-
except (StopIteration, PreventUpdate):
1037-
break
1038-
except Exception as err: # pylint: disable=broad-exception-caught
1039-
if error_handler:
1040-
output_value = error_handler(err)
1041-
if output_value is not None:
1042-
frame = _build_stream_frame(
1043-
output_value,
1044-
output_spec,
1045-
callback_ctx,
1046-
app,
1047-
original_packages,
1048-
)
1049-
if frame is not None:
1050-
yield frame
1051-
break
1052-
yield _stream_error_frame(err)
1053-
return
1054-
if frame is not None:
1055-
yield frame
1056-
yield {"done": True}
1057-
finally:
1058-
user_gen.close()
1059-
10601030
async def _astream_frames(
10611031
user_gen, error_handler, output_spec, callback_ctx, app, original_packages
10621032
):
1063-
# The whole stream is iterated from a single task (streaming
1064-
# response body or WS loop task), so setting the context var here
1065-
# makes ctx/set_props work for every step of the user generator.
1066-
token = context_value.set(callback_ctx)
1033+
# Set the callback context var around each resumption of the user
1034+
# generator so dash.ctx/set_props resolve while its body runs. It is
1035+
# set per step rather than once for the whole stream because the
1036+
# keepalive drivers resume each __anext__ in a freshly copied
1037+
# context, where a token taken in an earlier step could not be reset.
1038+
async def _next():
1039+
token = context_value.set(callback_ctx)
1040+
try:
1041+
return await user_gen.__anext__()
1042+
finally:
1043+
context_value.reset(token)
1044+
1045+
def _handle_error(err):
1046+
# Run the on_error handler under the callback context too so
1047+
# dash.ctx/set_props resolve inside it, matching a step.
1048+
token = context_value.set(callback_ctx)
1049+
try:
1050+
return error_handler(err)
1051+
finally:
1052+
context_value.reset(token)
1053+
10671054
try:
10681055
while True:
10691056
frame = None
10701057
try:
1071-
output_value = await user_gen.__anext__()
1058+
output_value = await _next()
10721059
frame = _build_stream_frame(
10731060
output_value,
10741061
output_spec,
@@ -1080,7 +1067,7 @@ async def _astream_frames(
10801067
break
10811068
except Exception as err: # pylint: disable=broad-exception-caught
10821069
if error_handler:
1083-
output_value = error_handler(err)
1070+
output_value = _handle_error(err)
10841071
if output_value is not None:
10851072
frame = _build_stream_frame(
10861073
output_value,
@@ -1098,40 +1085,8 @@ async def _astream_frames(
10981085
yield frame
10991086
yield {"done": True}
11001087
finally:
1101-
context_value.reset(token)
11021088
await user_gen.aclose()
11031089

1104-
@wraps(func)
1105-
def add_context_stream(*args, **kwargs):
1106-
"""Handles streaming callbacks defined as sync generators."""
1107-
error_handler = on_error or kwargs.pop("app_on_error", None)
1108-
1109-
(
1110-
output_spec,
1111-
callback_ctx,
1112-
func_args,
1113-
func_kwargs,
1114-
app,
1115-
original_packages,
1116-
_,
1117-
) = _initialize_context(
1118-
args, kwargs, inputs_state_indices, has_output, insert_output
1119-
)
1120-
1121-
# Creates the generator; the function body runs on first next().
1122-
user_gen = _invoke_callback(func, *func_args, **func_kwargs)
1123-
frames = _stream_frames(
1124-
user_gen,
1125-
error_handler,
1126-
output_spec,
1127-
callback_ctx,
1128-
app,
1129-
original_packages,
1130-
)
1131-
# Snapshot the context (includes the callback context set above) so
1132-
# the transport can drive the generator after dispatch returns.
1133-
return StreamedCallbackResponse(frames, is_async=False, ctx=copy_context())
1134-
11351090
@wraps(func)
11361091
async def async_add_context_stream(*args, **kwargs):
11371092
"""Handles streaming callbacks defined as async generators."""
@@ -1161,22 +1116,10 @@ async def async_add_context_stream(*args, **kwargs):
11611116
return StreamedCallbackResponse(frames, is_async=True)
11621117

11631118
if is_stream:
1119+
# Only async generators reach here; sync generators are rejected in
1120+
# _validate_stream_callback above.
11641121
callback_map[callback_id]["stream"] = True
1165-
if is_gen_func:
1166-
# A sync generator stream occupies a server worker (or WS
1167-
# executor thread) for the whole stream duration; recommend
1168-
# async so it runs on the event loop.
1169-
warnings.warn(
1170-
f"Streaming callback '{callback_id}' is a synchronous "
1171-
"generator; it will occupy a server worker for the whole "
1172-
"stream. Define it with 'async def' so it runs on the "
1173-
"event loop instead.",
1174-
RuntimeWarning,
1175-
stacklevel=2,
1176-
)
1177-
callback_map[callback_id]["callback"] = add_context_stream
1178-
else:
1179-
callback_map[callback_id]["callback"] = async_add_context_stream
1122+
callback_map[callback_id]["callback"] = async_add_context_stream
11801123
elif inspect.iscoroutinefunction(func):
11811124
callback_map[callback_id]["callback"] = async_add_context
11821125
else:

tests/integration/callbacks/test_stream_callbacks.py

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"""Browser integration tests for streaming callbacks over HTTP (NDJSON)."""
2+
import asyncio
23
import time
34

4-
import pytest
5-
65
from dash import (
76
Dash,
87
Input,
@@ -15,7 +14,6 @@
1514
from dash.testing.wait import until
1615

1716

18-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
1917
def test_stst001_stream_progressive_render(dash_duo):
2018
"""Intermediate yields render before the stream completes."""
2119
app = Dash(__name__)
@@ -31,11 +29,11 @@ def test_stst001_stream_progressive_render(dash_duo):
3129
Input("btn", "n_clicks"),
3230
prevent_initial_call=True,
3331
)
34-
def stream_cb(n):
32+
async def stream_cb(n):
3533
yield "step-1"
36-
time.sleep(0.5)
34+
await asyncio.sleep(0.5)
3735
yield "step-2"
38-
time.sleep(0.5)
36+
await asyncio.sleep(0.5)
3937
yield "done"
4038

4139
dash_duo.start_server(app)
@@ -47,7 +45,6 @@ def stream_cb(n):
4745
assert dash_duo.get_logs() == []
4846

4947

50-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
5148
def test_stst002_stream_patch_appends_once(dash_duo):
5249
"""Patch yields apply exactly once (token streaming)."""
5350
app = Dash(__name__)
@@ -63,10 +60,10 @@ def test_stst002_stream_patch_appends_once(dash_duo):
6360
Input("btn", "n_clicks"),
6461
prevent_initial_call=True,
6562
)
66-
def stream_cb(n):
63+
async def stream_cb(n):
6764
yield "->"
6865
for token in ["alpha", "beta", "gamma"]:
69-
time.sleep(0.2)
66+
await asyncio.sleep(0.2)
7067
patch = Patch()
7168
patch += token
7269
yield patch
@@ -81,7 +78,6 @@ def stream_cb(n):
8178
assert dash_duo.get_logs() == []
8279

8380

84-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
8581
def test_stst003_stream_multi_output_and_set_props(dash_duo):
8682
app = Dash(__name__)
8783
app.layout = html.Div(
@@ -99,9 +95,9 @@ def test_stst003_stream_multi_output_and_set_props(dash_duo):
9995
Input("btn", "n_clicks"),
10096
prevent_initial_call=True,
10197
)
102-
def stream_cb(n):
98+
async def stream_cb(n):
10399
yield "a1", no_update
104-
time.sleep(0.3)
100+
await asyncio.sleep(0.3)
105101
set_props("side", {"children": "from-set-props"})
106102
yield no_update, "b1"
107103

@@ -113,7 +109,6 @@ def stream_cb(n):
113109
assert dash_duo.get_logs() == []
114110

115111

116-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
117112
def test_stst004_stream_triggers_downstream_callback(dash_duo):
118113
"""The final streamed value triggers dependent callbacks."""
119114
app = Dash(__name__)
@@ -130,9 +125,9 @@ def test_stst004_stream_triggers_downstream_callback(dash_duo):
130125
Input("btn", "n_clicks"),
131126
prevent_initial_call=True,
132127
)
133-
def stream_cb(n):
128+
async def stream_cb(n):
134129
yield "one"
135-
time.sleep(0.2)
130+
await asyncio.sleep(0.2)
136131
yield "two"
137132

138133
@app.callback(
@@ -149,7 +144,6 @@ def downstream(value):
149144
assert dash_duo.get_logs() == []
150145

151146

152-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
153147
def test_stst005_stream_error_shows_in_devtools(dash_duo):
154148
app = Dash(__name__)
155149
app.layout = html.Div(
@@ -164,7 +158,7 @@ def test_stst005_stream_error_shows_in_devtools(dash_duo):
164158
Input("btn", "n_clicks"),
165159
prevent_initial_call=True,
166160
)
167-
def stream_cb(n):
161+
async def stream_cb(n):
168162
yield "before-error"
169163
raise ValueError("stream blew up")
170164

@@ -176,7 +170,6 @@ def stream_cb(n):
176170
dash_duo.wait_for_text_to_equal(".test-devtools-error-count", "1")
177171

178172

179-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
180173
def test_stst006_stream_loading_state(dash_duo):
181174
"""The callback stays in loading state for the whole stream."""
182175
app = Dash(__name__)
@@ -192,9 +185,9 @@ def test_stst006_stream_loading_state(dash_duo):
192185
Input("btn", "n_clicks"),
193186
prevent_initial_call=True,
194187
)
195-
def stream_cb(n):
188+
async def stream_cb(n):
196189
yield "working"
197-
time.sleep(1.5)
190+
await asyncio.sleep(1.5)
198191
yield "finished"
199192

200193
dash_duo.start_server(app)

0 commit comments

Comments
 (0)