diff --git a/dash/_callback.py b/dash/_callback.py index 1b92907df1..fb1952f762 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -635,6 +635,7 @@ def _update_background_callback( kwargs, background, multi, + output_spec, cache_key=None, job_id=None, ): @@ -663,6 +664,7 @@ def _update_background_callback( error_handler, callback_ctx, multi, + output_spec, cache_key=cache_key, job_id=job_id, ) @@ -675,6 +677,7 @@ def _handle_rest_background_callback( error_handler, callback_ctx, multi, + output_spec, has_update=False, cache_key=None, job_id=None, @@ -693,7 +696,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 @@ -927,7 +930,13 @@ 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 @@ -998,7 +1007,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 + error_handler, + callback_ctx, + response, + kwargs, + background, + multi, + output_spec, ) if skip: return output_value 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/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..c90731a71b --- /dev/null +++ b/tests/background_callback/test_basic_long_callback019.py @@ -0,0 +1,12 @@ +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_contains_text( + "#no-output-cb-onerror-output", + "callback: An error occurred inside a background callback: no output 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 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