Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/test_main_module_alias.py
Original file line number Diff line number Diff line change
@@ -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"
]
Loading