From a0e38209dc54c10b7c8e762e67060ff860cca875 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 7 Jul 2026 15:53:26 -0400 Subject: [PATCH] Fix callbacks double-registered when app file is loaded by import string When the app module runs as a script, a server that loads it by import string (e.g. uvicorn.run("app:server", reload=True)) executes the same file twice in the worker process: multiprocessing spawn re-runs it as __mp_main__, then the import string imports it again under its real name. Both passes run the @callback decorators, duplicating every spec in _dash-dependencies and triggering "Duplicate callback outputs" errors in the renderer. Dash() now pre-registers the running main module in sys.modules under its canonical import name (only when that name resolves to the same file and isn't already imported), so the second import reuses the already-executed module instead of re-executing the file. Fixes #3818 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ dash/dash.py | 33 +++++++++ tests/unit/test_main_module_alias.py | 102 +++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 tests/unit/test_main_module_alias.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7b816d7..77f34fd6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +## Fixed +- [](https://github.com/plotly/dash/pull/) Fix callbacks being registered twice when running the app file as a script with a server that loads it by import string, e.g. `uvicorn.run("app:server", reload=True)` with `backend="fastapi"`. The spawned worker re-executes the main module as `__mp_main__` and the import string then executed the same file a second time, duplicating every callback in `_dash-dependencies` and triggering `Duplicate callback outputs` errors in the renderer. `Dash()` now pre-registers the running main module in `sys.modules` under its canonical import name so the second import reuses it instead of re-executing the file. Fixes [#3818](https://github.com/plotly/dash/issues/3818). + ## [4.4.0] - 2026-07-03 ### Added diff --git a/dash/dash.py b/dash/dash.py index 36a12c6d73..ff284bfbeb 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -154,6 +154,37 @@ page_container = None +def _alias_main_module(caller_name: str) -> None: + # When the app module runs as a script (`__main__`), or is re-executed by + # multiprocessing's spawn as `__mp_main__` (e.g. in the worker process of + # uvicorn's reloader), a later import of the same file by its real name + # ("app:server" import strings) would execute the module a second time, + # registering every callback twice. Pre-register the running module under + # its canonical import name so that import resolves to this module + # instead of re-executing the file. See issue #3818. + if caller_name not in ("__main__", "__mp_main__"): + return + module = sys.modules.get(caller_name) + if module is None: + return + module_file = getattr(module, "__file__", None) + if not module_file: + return + import_name = os.path.splitext(os.path.basename(module_file))[0] + if not import_name.isidentifier() or import_name in sys.modules: + return + try: + spec = find_spec(import_name) + if ( + spec is not None + and spec.origin is not None + and os.path.samefile(spec.origin, module_file) + ): + sys.modules[import_name] = module + except (ImportError, ValueError, OSError): + pass + + def _get_traceback(secret, error: Exception): try: # pylint: disable=import-outside-toplevel @@ -506,6 +537,8 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches caller_name: str = name if name is not None else get_caller_name() + _alias_main_module(caller_name) + # Determine backend if backend is None: backend_cls = get_backend("flask") diff --git a/tests/unit/test_main_module_alias.py b/tests/unit/test_main_module_alias.py new file mode 100644 index 0000000000..3d9358daa9 --- /dev/null +++ b/tests/unit/test_main_module_alias.py @@ -0,0 +1,102 @@ +"""Regression tests for https://github.com/plotly/dash/issues/3818. + +When the app module runs as ``__main__``/``__mp_main__`` (e.g. in the worker +process of uvicorn's reloader, which multiprocessing spawn re-executes as +``__mp_main__``), the server's import string ("app:server") imports the same +file a second time under its real name. Both executions run the module-level +``@callback`` decorators, duplicating every spec in GLOBAL_CALLBACK_LIST and +producing `Duplicate callback outputs` errors in the renderer. + +``Dash.__init__`` now pre-registers the running main module in ``sys.modules`` +under its canonical import name so the second import resolves to the module +already executed instead of re-executing the file. +""" +import importlib +import sys +import types + +APP_SOURCE = """ +from dash import Dash, html, dcc, callback, Output, Input + +app = Dash(__name__) +app.layout = html.Div([ + dcc.Input(id="alias-in", value="hello"), + html.Div(id="alias-out"), +]) + + +@callback(Output("alias-out", "children"), Input("alias-in", "value")) +def update(value): + return value + + +server = app.server +""" + +MODULE_NAME = "dash_test_alias_app" + + +def _run_as(app_file, run_name): + """Execute the app file the way multiprocessing spawn runs the main module.""" + module = types.ModuleType(run_name) + module.__file__ = str(app_file) + sys.modules[run_name] = module + code = compile(app_file.read_text(), str(app_file), "exec") + exec(code, module.__dict__) # pylint: disable=exec-used + return module + + +def test_main_module_alias_prevents_double_registration(tmp_path, monkeypatch): + from dash import _callback + + app_file = tmp_path / f"{MODULE_NAME}.py" + app_file.write_text(APP_SOURCE) + monkeypatch.syspath_prepend(str(tmp_path)) + + try: + main_module = _run_as(app_file, "__mp_main__") + + # The import string import ("dash_test_alias_app:server") must resolve + # to the module that already executed, not re-execute the file. + imported = importlib.import_module(MODULE_NAME) + assert imported is main_module + + specs = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] == "alias-out.children" + ] + assert len(specs) == 1 + assert "alias-out.children" in _callback.GLOBAL_CALLBACK_MAP + finally: + sys.modules.pop("__mp_main__", None) + sys.modules.pop(MODULE_NAME, None) + _callback.GLOBAL_CALLBACK_MAP.pop("alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "alias-out.children" + ] + + +def test_no_alias_when_names_collide(tmp_path, monkeypatch): + """A main module whose basename matches an already-imported module must + not clobber the existing sys.modules entry (e.g. a script named dash.py).""" + app_file = tmp_path / "dash.py" + app_file.write_text(APP_SOURCE) + monkeypatch.syspath_prepend(str(tmp_path)) + + import dash as real_dash + from dash import _callback + + try: + _run_as(app_file, "__mp_main__") + assert sys.modules["dash"] is real_dash + finally: + sys.modules.pop("__mp_main__", None) + _callback.GLOBAL_CALLBACK_MAP.pop("alias-out.children", None) + _callback.GLOBAL_CALLBACK_LIST[:] = [ + spec + for spec in _callback.GLOBAL_CALLBACK_LIST + if spec["output"] != "alias-out.children" + ]