From cce590e9f09a7e69a8b3eb073243ff5f7e81db29 Mon Sep 17 00:00:00 2001 From: "shuwen.wu" Date: Fri, 25 Sep 2026 11:56:58 +0800 Subject: [PATCH 1/8] fix: handle None return from error handler in no-output background callbacks When a background callback with no outputs raises an exception, the error handler is called. If the error handler returns None, the code was incorrectly setting output_value = NoUpdate(), which then triggered an InvalidCallbackReturnValue exception at the output validation step. This change adds the same output_spec check that exists in the non-background callback path (line 866) to the background callback path (line 635), ensuring that NoUpdate() is only set when there are actual outputs to update. Fixes plotly/dash#3628 --- dash/_callback.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 1a24bdf621..c400a3c3a1 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -574,6 +574,7 @@ def _update_background_callback( kwargs, background, multi, + output_spec, cache_key=None, job_id=None, ): @@ -602,6 +603,7 @@ def _update_background_callback( error_handler, callback_ctx, multi, + output_spec, cache_key=cache_key, job_id=job_id, ) @@ -614,6 +616,7 @@ def _handle_rest_background_callback( error_handler, callback_ctx, multi, + output_spec, has_update=False, cache_key=None, job_id=None, @@ -632,7 +635,7 @@ def _handle_rest_background_callback( if error_handler: output_value = error_handler(exc) - if output_value is None: + if output_value is None and output_spec: output_value = NoUpdate() # set_props from the error handler uses the original ctx # instead of manager.get_updated_props since it runs in the @@ -852,7 +855,7 @@ def add_context(*args, **kwargs): ) output_value, has_update, skip = _update_background_callback( - error_handler, callback_ctx, response, kwargs, background, multi + error_handler, callback_ctx, response, kwargs, background, multi, output_spec ) if skip: return output_value @@ -923,7 +926,7 @@ async def async_add_context(*args, **kwargs): callback_ctx, ) output_value, has_update, skip = _update_background_callback( - error_handler, callback_ctx, response, kwargs, background, multi + error_handler, callback_ctx, response, kwargs, background, multi, output_spec ) if skip: return output_value From 38bd0e7c45e300cf45aebcc4ee81c3e66dd8a7b3 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 30 Sep 2026 16:02:06 +0800 Subject: [PATCH 2/8] fix: pass output_spec to MCP background task handler --- dash/mcp/tasks/tasks.py | 1 + .../tools/test_mcp_background_callbacks.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/dash/mcp/tasks/tasks.py b/dash/mcp/tasks/tasks.py index 5f0ac3faf2..1b0e713855 100644 --- a/dash/mcp/tasks/tasks.py +++ b/dash/mcp/tasks/tasks.py @@ -162,6 +162,7 @@ def get_task_result(task_id: str) -> Any: kwargs={"background_callback_manager": manager}, background=background, multi=multi, + output_spec=output_spec, cache_key=cache_key, job_id=job_id, ) diff --git a/tests/unit/mcp/tools/test_mcp_background_callbacks.py b/tests/unit/mcp/tools/test_mcp_background_callbacks.py index 8a9b96bbb8..509ff26d50 100644 --- a/tests/unit/mcp/tools/test_mcp_background_callbacks.py +++ b/tests/unit/mcp/tools/test_mcp_background_callbacks.py @@ -10,6 +10,7 @@ import json import time +from types import SimpleNamespace import diskcache from dash import Dash, Input, Output, html, _callback_signing @@ -298,3 +299,53 @@ def test_mcpbg011_task_id_encodes_tool_name_job_id_cache_key(): assert job_id.isdigit() assert len(cache_key) == 64 # SHA256 hex assert created_epoch.isdigit() + + +def test_mcpbg012_tasks_result_passes_output_spec(monkeypatch): + from dash.mcp.tasks import tasks + + output_spec = [{"id": "output", "property": "children"}] + adapter = SimpleNamespace( + _cb_info={"background": object(), "no_output": False}, + output_id="output.children", + as_callback_body=lambda _params: {"outputs": output_spec}, + ) + app = SimpleNamespace( + mcp_callback_map=SimpleNamespace(find_by_tool_name=lambda _name: adapter) + ) + manager = object() + captured = {} + + def update_background_callback( + error_handler, + callback_ctx, + response, + kwargs, + background, + multi, + output_spec, + cache_key=None, + job_id=None, + ): + captured["output_spec"] = output_spec + return None, False, False + + monkeypatch.setattr( + tasks, + "parse_task_id", + lambda _task_id: ("tool", "job", "cache", None), + ) + monkeypatch.setattr(tasks, "get_app", lambda: app) + monkeypatch.setattr(tasks, "_get_callback_manager", lambda _tool_name: manager) + monkeypatch.setattr( + tasks, "_update_background_callback", update_background_callback + ) + monkeypatch.setattr(tasks, "_prepare_response", lambda *args, **kwargs: None) + monkeypatch.setattr( + tasks, + "format_callback_response", + lambda _response, _adapter: "formatted", + ) + + assert tasks.get_task_result("task-id") == "formatted" + assert captured["output_spec"] is output_spec From f267f5868295fed4e44f3b77fcb235876e0c29c5 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 30 Sep 2026 17:00:25 +0800 Subject: [PATCH 3/8] style: format callback error handling --- dash/_callback.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 534e770bf0..2fe197766b 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -312,12 +312,10 @@ def _validate_stream_callback(callback_id, background, kwargs, is_sync_gen): def validate_background_inputs(deps): for dep in deps: if dep.has_wildcard(): - raise WildcardInLongCallback( - f""" + raise WildcardInLongCallback(f""" background callbacks does not support dependencies with pattern-matching ids - Received: {repr(dep)}\n""" - ) + Received: {repr(dep)}\n""") ClientsideFuncType = Union[str, ClientsideFunction] @@ -930,7 +928,13 @@ def add_context(*args, **kwargs): ) output_value, has_update, skip = _update_background_callback( - error_handler, callback_ctx, response, kwargs, background, multi, output_spec + error_handler, + callback_ctx, + response, + kwargs, + background, + multi, + output_spec, ) if skip: return output_value @@ -1001,7 +1005,13 @@ async def async_add_context(*args, **kwargs): callback_ctx, ) output_value, has_update, skip = _update_background_callback( - error_handler, callback_ctx, response, kwargs, background, multi, output_spec + error_handler, + callback_ctx, + response, + kwargs, + background, + multi, + output_spec, ) if skip: return output_value From c0be071fd7559ade83e5718981285fb77911bf91 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 30 Sep 2026 18:15:02 +0800 Subject: [PATCH 4/8] style: match pinned Black formatting --- dash/_callback.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 2fe197766b..fb1952f762 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -312,10 +312,12 @@ def _validate_stream_callback(callback_id, background, kwargs, is_sync_gen): def validate_background_inputs(deps): for dep in deps: if dep.has_wildcard(): - raise WildcardInLongCallback(f""" + raise WildcardInLongCallback( + f""" background callbacks does not support dependencies with pattern-matching ids - Received: {repr(dep)}\n""") + Received: {repr(dep)}\n""" + ) ClientsideFuncType = Union[str, ClientsideFunction] From 054249f8312771a3abe604d03b2cfada498d0629 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 30 Sep 2026 18:29:06 +0800 Subject: [PATCH 5/8] test: cover no-output background callback errors --- tests/background_callback/app_bg_on_error.py | 16 ++++++++++++++++ .../test_basic_long_callback019.py | 11 +++++++++++ 2 files changed, 27 insertions(+) create mode 100644 tests/background_callback/test_basic_long_callback019.py diff --git a/tests/background_callback/app_bg_on_error.py b/tests/background_callback/app_bg_on_error.py index 724531f092..3945695e53 100644 --- a/tests/background_callback/app_bg_on_error.py +++ b/tests/background_callback/app_bg_on_error.py @@ -20,6 +20,8 @@ def global_error_handler(err): html.Div(id="cb-output"), html.Button("global on_error", id="start-global-onerror"), html.Div(id="global-output"), + html.Button("no-output callback on_error", id="start-no-output-cb-onerror"), + html.Div(id="no-output-cb-onerror-output"), ] @@ -48,5 +50,19 @@ def on_click_global(_): raise Exception("global error") +def no_output_callback_on_error(err): + set_props("no-output-cb-onerror-output", {"children": f"callback: {err}"}) + + +@app.callback( + Input("start-no-output-cb-onerror", "n_clicks"), + prevent_initial_call=True, + background=True, + on_error=no_output_callback_on_error, +) +def on_click_no_output(_): + raise Exception("no output callback error") + + if __name__ == "__main__": app.run(debug=True) diff --git a/tests/background_callback/test_basic_long_callback019.py b/tests/background_callback/test_basic_long_callback019.py new file mode 100644 index 0000000000..dd3b89ff53 --- /dev/null +++ b/tests/background_callback/test_basic_long_callback019.py @@ -0,0 +1,11 @@ +from tests.background_callback.utils import setup_background_callback_app + + +def test_lcbc019_background_callback_on_error_without_output(dash_duo, manager): + with setup_background_callback_app(manager, "app_bg_on_error") as app: + dash_duo.start_server(app) + + dash_duo.find_element("#start-no-output-cb-onerror").click() + dash_duo.wait_for_text_to_equal( + "#no-output-cb-onerror-output", "callback: no output callback error" + ) From ebf5f954316e3d87de185739bc1b5d66f16a3d59 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 30 Sep 2026 19:50:57 +0800 Subject: [PATCH 6/8] test: assert no-output callback error handling --- tests/background_callback/test_basic_long_callback019.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/background_callback/test_basic_long_callback019.py b/tests/background_callback/test_basic_long_callback019.py index dd3b89ff53..c90731a71b 100644 --- a/tests/background_callback/test_basic_long_callback019.py +++ b/tests/background_callback/test_basic_long_callback019.py @@ -6,6 +6,7 @@ def test_lcbc019_background_callback_on_error_without_output(dash_duo, manager): dash_duo.start_server(app) dash_duo.find_element("#start-no-output-cb-onerror").click() - dash_duo.wait_for_text_to_equal( - "#no-output-cb-onerror-output", "callback: no output callback error" + dash_duo.wait_for_contains_text( + "#no-output-cb-onerror-output", + "callback: An error occurred inside a background callback: no output callback error", ) From fc7f0616cde479a03c56b8e39b2142a7025fa4c4 Mon Sep 17 00:00:00 2001 From: "shuwen.wu" Date: Wed, 7 Oct 2026 18:01:26 +0800 Subject: [PATCH 7/8] fix: apply output_spec guard to cancel branch and simplify tests - The cancelled-job branch in _handle_rest_background_callback now sets NoUpdate() only when output_spec is truthy (callbacks with outputs), matching the error-handler guard added earlier. Without outputs, output_value stays None so _prepare_response does not raise InvalidCallbackReturnValue. - Replace the heavily-mocked test_mcpbg012 with two integration-style tests that register a real no-output background callback via @app.callback and exercise both the completed and cancelled paths through the actual MCP tasks/result endpoint. - Add CHANGELOG entry for #3628 / #4012. --- CHANGELOG.md | 1 + dash/_callback.py | 5 +- .../tools/test_mcp_background_callbacks.py | 96 ++++++++++--------- 3 files changed, 58 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f31991146e..28325c7bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3881](https://github.com/plotly/dash/pull/3881) Fix components rendered as props (eg. `dcc.Dropdown` option labels, `dcc.Tab` labels) crashing or failing to update when the host subtree was replaced by a callback; out-of-tree `ExternalWrapper` components now re-insert themselves and update in place. - [#3929](https://github.com/plotly/dash/issues/3929) Fix components that set their own initial state on mount (eg. `dash-bootstrap-components` `Tabs`) not applying it on first render, because descendant layout hashes were reset on the first fresh render (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). - [#3948](https://github.com/plotly/dash/issues/3948) Fix page getting progressively slower as callbacks append children +- [#4012](https://github.com/plotly/dash/pull/4012) Fix `InvalidCallbackReturnValue` raised when a no-output background callback's error handler returns `None` (or the job is cancelled), by treating `None` returns from error handlers and the cancelled-job branch the same way — as `NoUpdate()` only when there are outputs, `None` otherwise. Fixes [#3628](https://github.com/plotly/dash/issues/3628). ## [4.4.1] - 2026-07-21 diff --git a/dash/_callback.py b/dash/_callback.py index fb1952f762..800b5db2e8 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -686,7 +686,10 @@ def _handle_rest_background_callback( job_running = callback_manager.job_running(job_id) if job_id is not None else False if not job_running and output_value is callback_manager.UNDEFINED: # Job canceled -> no output to close the loop. - output_value = NoUpdate() + if output_spec: + output_value = NoUpdate() + else: + output_value = None elif isinstance(output_value, dict) and "background_callback_error" in output_value: error = output_value.get("background_callback_error", {}) diff --git a/tests/unit/mcp/tools/test_mcp_background_callbacks.py b/tests/unit/mcp/tools/test_mcp_background_callbacks.py index 509ff26d50..95354ce4dd 100644 --- a/tests/unit/mcp/tools/test_mcp_background_callbacks.py +++ b/tests/unit/mcp/tools/test_mcp_background_callbacks.py @@ -10,7 +10,6 @@ import json import time -from types import SimpleNamespace import diskcache from dash import Dash, Input, Output, html, _callback_signing @@ -301,51 +300,62 @@ def test_mcpbg011_task_id_encodes_tool_name_job_id_cache_key(): assert created_epoch.isdigit() -def test_mcpbg012_tasks_result_passes_output_spec(monkeypatch): - from dash.mcp.tasks import tasks +def _make_no_output_background_app(): + cache = diskcache.Cache() + manager = DiskcacheManager(cache) - output_spec = [{"id": "output", "property": "children"}] - adapter = SimpleNamespace( - _cb_info={"background": object(), "no_output": False}, - output_id="output.children", - as_callback_body=lambda _params: {"outputs": output_spec}, - ) - app = SimpleNamespace( - mcp_callback_map=SimpleNamespace(find_by_tool_name=lambda _name: adapter) + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id="trigger-no-output"), + html.Div(id="side-effect-target"), + ] ) - manager = object() - captured = {} - - def update_background_callback( - error_handler, - callback_ctx, - response, - kwargs, - background, - multi, - output_spec, - cache_key=None, - job_id=None, - ): - captured["output_spec"] = output_spec - return None, False, False - - monkeypatch.setattr( - tasks, - "parse_task_id", - lambda _task_id: ("tool", "job", "cache", None), + + @app.callback( + Input("trigger-no-output", "children"), + background=True, + manager=manager, ) - monkeypatch.setattr(tasks, "get_app", lambda: app) - monkeypatch.setattr(tasks, "_get_callback_manager", lambda _tool_name: manager) - monkeypatch.setattr( - tasks, "_update_background_callback", update_background_callback + def no_output_slow_callback(value): + time.sleep(0.5) + + return app + + +def test_mcpbg012_no_output_task_result_does_not_raise(): + app = _make_no_output_background_app() + create_result = _mcp( + app, + "tools/call", + { + "name": "no_output_slow_callback", + "arguments": {"value": "hello"}, + "task": {"ttl": 60000}, + }, ) - monkeypatch.setattr(tasks, "_prepare_response", lambda *args, **kwargs: None) - monkeypatch.setattr( - tasks, - "format_callback_response", - lambda _response, _adapter: "formatted", + task_id = create_result["result"]["task"]["taskId"] + _wait_for_completion(app, task_id) + + result = _mcp(app, "tasks/result", {"taskId": task_id}) + assert "error" not in result + + +def test_mcpbg013_cancel_no_output_task_does_not_raise(): + app = _make_no_output_background_app() + create_result = _mcp( + app, + "tools/call", + { + "name": "no_output_slow_callback", + "arguments": {"value": "hello"}, + "task": {"ttl": 60000}, + }, ) + task_id = create_result["result"]["task"]["taskId"] - assert tasks.get_task_result("task-id") == "formatted" - assert captured["output_spec"] is output_spec + cancel_result = _mcp(app, "tasks/cancel", {"taskId": task_id}) + assert "error" not in cancel_result + + result = _mcp(app, "tasks/result", {"taskId": task_id}) + assert "error" not in result From f5cde05aa48f3f02db6ebf398c2437207de72b3b Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Fri, 16 Oct 2026 03:01:54 +0800 Subject: [PATCH 8/8] Fix MCP task results for no-output background callbacks --- dash/mcp/tasks/tasks.py | 36 ++++++++++-------- .../mcp/test_mcp_background_tasks.py | 37 +++++++++++++++++++ .../tools/test_mcp_background_callbacks.py | 4 +- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/dash/mcp/tasks/tasks.py b/dash/mcp/tasks/tasks.py index 1b0e713855..f200ce3abf 100644 --- a/dash/mcp/tasks/tasks.py +++ b/dash/mcp/tasks/tasks.py @@ -12,6 +12,7 @@ from dash._callback import _update_background_callback, _prepare_response from dash._utils import AttributeDict, split_callback_id from dash.development.base_component import ComponentRegistry +from dash.exceptions import PreventUpdate from dash.mcp.primitives.tools.results import format_callback_response from dash.mcp.types import MCPError from dash.types import CallbackExecutionResponse @@ -146,7 +147,7 @@ def get_task_result(task_id: str) -> Any: background = cb_info.get("background") has_output = not cb_info.get("no_output") multi = adapter.output_id.startswith("..") - output = split_callback_id(adapter.output_id) + output = split_callback_id(adapter.output_id) if has_output else [] # Build the body to get output_spec, same as as_callback_body body = adapter.as_callback_body({}) @@ -173,21 +174,24 @@ def get_task_result(task_id: str) -> Any: "Task result not ready. Poll tasks/get until status is 'completed'." ) - _prepare_response( - output_value, - output_spec, - multi, - response, - callback_ctx, - app, - set(ComponentRegistry.registry), - background, - has_update, - has_output, - output, - adapter.output_id, - cb_info.get("allow_dynamic_callbacks"), - ) + try: + _prepare_response( + output_value, + output_spec, + multi, + response, + callback_ctx, + app, + set(ComponentRegistry.registry), + background, + has_update, + has_output, + output, + adapter.output_id, + cb_info.get("allow_dynamic_callbacks"), + ) + except PreventUpdate: + return format_callback_response(response, adapter) return format_callback_response(response, adapter) diff --git a/tests/integration/mcp/test_mcp_background_tasks.py b/tests/integration/mcp/test_mcp_background_tasks.py index 3bbfcfebef..c3967dae6f 100644 --- a/tests/integration/mcp/test_mcp_background_tasks.py +++ b/tests/integration/mcp/test_mcp_background_tasks.py @@ -17,6 +17,8 @@ from dash import Dash, Input, Output, html, _callback_signing from dash.background_callback.managers.diskcache_manager import DiskcacheManager +from tests.integration.mcp.conftest import _mcp_method + MCP_PATH = "_mcp" @@ -89,6 +91,41 @@ def _post(client, method, params=None, request_id=1): # --------------------------------------------------------------------------- +def test_mcpbg_cancel_no_output_task_result(dash_duo): + manager = DiskcacheManager(diskcache.Cache()) + app = Dash(__name__) + app.layout = html.Div(id="trigger") + + @app.callback( + Input("trigger", "children"), + background=True, + manager=manager, + prevent_initial_call=True, + ) + def no_output_callback(value): + time.sleep(0.5) + + dash_duo.start_server(app) + created = _mcp_method( + dash_duo.server_url, + "tools/call", + { + "name": "no_output_callback", + "arguments": {"value": "hello"}, + "task": {"ttl": 60000}, + }, + ) + task_id = created["result"]["task"]["taskId"] + job_id, _ = _unwrap_handles(app, task_id) + cancelled = _mcp_method(dash_duo.server_url, "tasks/cancel", {"taskId": task_id}) + assert cancelled["result"]["status"] == "cancelled" + assert not manager.job_running(job_id) + + result = _mcp_method(dash_duo.server_url, "tasks/result", {"taskId": task_id}) + assert result["result"]["structuredContent"] == {"multi": True} + assert dash_duo.get_logs() == [] + + def test_mcpbg012_trigger_poll_and_retrieve(): app = _make_background_app() client = app.server.test_client() diff --git a/tests/unit/mcp/tools/test_mcp_background_callbacks.py b/tests/unit/mcp/tools/test_mcp_background_callbacks.py index 0b27f844c1..aee3dcf3d5 100644 --- a/tests/unit/mcp/tools/test_mcp_background_callbacks.py +++ b/tests/unit/mcp/tools/test_mcp_background_callbacks.py @@ -340,7 +340,7 @@ def test_mcpbg012_no_output_task_result_does_not_raise(): _wait_for_completion(app, task_id) result = _mcp(app, "tasks/result", {"taskId": task_id}) - assert "error" not in result + assert result["result"]["structuredContent"] == {"multi": True} def test_mcpbg013_cancel_no_output_task_does_not_raise(): @@ -360,4 +360,4 @@ def test_mcpbg013_cancel_no_output_task_does_not_raise(): assert "error" not in cancel_result result = _mcp(app, "tasks/result", {"taskId": task_id}) - assert "error" not in result + assert result["result"]["structuredContent"] == {"multi": True}