[BUG] Race condition in Dash._setup_server(): guard flag is set before the work it protects
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 45/100
Línea de trabajo
Comienza en dash.py, en Dash.init y _setup_server(), centrándote en el guard de configuración, registered_paths, callback_map y el guard de pages en router_async y router_sync. Ejecuta los scripts de reproducción enlazados con solicitudes concurrentes; se considera terminado cuando la inicialización está serializada y las primeras solicitudes concurrentes nunca observan un estado vacío o parcialmente poblado.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Describe your context
dash 4.4.1
dash_ag_grid 35.3.0 (only used by the first script, to have a component bundle to fetch;
the second script needs dash alone)
Flask 3.1.3 · Werkzeug 3.1.8 · gunicorn 26.2.0 (gthread, 4 workers × 4 threads) · Python 3.13
This is a server-side race, not a frontend bug, although the visible symptom is in the browser.
- OS: Linux aarch64; also reproduced independently on Linux x86-64 in Docker
Describe the bug
Dash._setup_server() runs as a before_request hook and is meant to execute once per
process. It sets its guard flag before performing the work that flag protects:
def _setup_server(self): # dash.py:1702
if self._got_first_request["setup_server"]:
return # <-- thread B leaves here
self._got_first_request["setup_server"] = True # <-- flag set BEFORE the work
...
_validate.validate_layout(self.layout, self._layout_value()) # 1724
self._generate_scripts_html() # 1726, fills registered_paths
...
self.callback_map[k] = _callback.GLOBAL_CALLBACK_MAP.pop(k) # 1737, fills callback_map
This is a TOCTOU. On any WSGI server that serves more than one request at a time in a single
process — gunicorn -k gthread --threads N, waitress, werkzeug with threaded=True, uWSGI
with threads — a second thread can enter while the first is still inside _setup_server, find
the flag already set, return immediately, and then read state the first thread has not finished
writing. It only affects the first page load after a process starts, and a refresh makes it
go away, which is why it is easy to dismiss as a caching or network glitch.
Both symptoms below are reproduced deterministically by the scripts linked further down.
1. registered_paths still empty → every component-suites request 500s. A browser asks for
the index and its JS bundles on parallel connections. The index thread is inside
_setup_server; the bundle threads skip it and reach validate_js_path against an empty
allowlist:
dash.exceptions.DependencyException: Error loading dependency. "dash_ag_grid" is not a
registered library.
Registered libraries are:
[]
The browser gets HTML where it expected JavaScript (Refused to execute script ... MIME type ('text/html')), so grids and clientside code are silently broken on that load. This is what we
hit in production.
2. callback_map still incomplete → callbacks 500 with KeyError. Callbacks registered
with dash.callback (module level — how every pages-based app registers them) are moved from
GLOBAL_CALLBACK_MAP into self.callback_map only inside _setup_server. A thread that skips
the setup carries on to _prepare_callback:
File "dash/dash.py", line 1628, in _prepare_callback
cb = self.callback_map[output]
KeyError: 'out.children'
...
KeyError: "Callback function not found for output 'out.children'."
The second script includes the control that makes this meaningful: the same request, replayed
once initialisation has finished, returns 200.
Possibly related, not reproduced. #2925 reports RuntimeError: dictionary changed size during iteration with an identical signature ("occurs on startup and initial access … a full
refresh (Ctrl+F5) resolves the issue … affecting the first user to visit the app after startup
… becomes more frequent as callback numbers increase"). In 4.4.1 the only iteration of
callback_map on that path is validate_background_callbacks (dash.py:1754, called from
inside _setup_server; it was validate_long_callbacks in the 2.x line #2925 reports), so that
error would require two threads inside the body at once — possible in principle, since the
check and the set are separate statements and both threads can pass the check before either one
sets the flag. I could not trigger that: 0 occurrences in 400 trials × 8 threads released
through a barrier with sys.setswitchinterval(1e-6). I mention it because the two symptoms I
did reproduce show that this state really is observable half-written, but I am not claiming
#2925 is proven to be the same bug.
Steps to reproduce. Deterministic, with no monkeypatching of Dash. The only "slow" ingredient
is a layout function that takes a moment — an ordinary application property, and the pattern
Dash documents as app.layout = serve_layout. It matters because _setup_server evaluates the
layout before filling registered_paths and callback_map, so it widens the pre-existing
window. Ordering of the requests is arranged on the client side only.
Reproduction scripts: https://gist.github.com/a-tram/1a522f6f2beb388543de7d5f18644962
pip install dash dash-ag-grid
python repro_setup_server_race.py # symptom 1
python repro_callback_map_race.py # symptom 2, with a control
Output on dash 4.4.1, identical on 5 consecutive runs each:
index: 200
bundle x40: {500: 40}
REPRODUCED
index: 200
update-component x20: {500: 20}
control (same request, setup finished): 200
REPRODUCED
Every one of those bundle 500s is the DependencyException quoted above, with Registered libraries are: [].
Note that gunicorn's default worker class is sync (one request at a time per worker), so the
canonical gunicorn app:server -w 4 deployment is immune. Only threaded deployments are
exposed, which probably explains why this is not reported more often. --preload does not help
either: _setup_server is a before_request hook, so it still runs after the fork, in every
worker.
Expected behavior
The first request to a freshly started process should not be able to observe half-initialised
state. Concurrent first requests should either wait for initialisation to finish or all see it
complete — never a raised flag next to an empty registered_paths or a partially filled
callback_map.
Suggested fix. Moving the flag after the work is not sufficient on its own: two threads would
then both run the full setup, and the GLOBAL_CALLBACK_MAP.pop(k) loop is not safe to run
twice. Double-checked locking keeps the warm path lock-free, and also closes the second window
noted above — today the check and the set are separate statements, so the flag does not prevent
two threads from running the whole body at once:
# in __init__
self._setup_lock = threading.Lock()
def _setup_server(self):
if self._got_first_request["setup_server"]:
return
with self._setup_lock:
if self._got_first_request["setup_server"]:
return
... # existing body, unchanged
self._got_first_request["setup_server"] = True # set LAST
The same check-then-set-then-work shape appears for the "pages" key in dash.py
(router_async and router_sync), so it may be worth auditing that one as well.
Workaround for anyone hitting this now. Force the initialisation at worker start, where no
other thread can race it — for gunicorn, in post_worker_init:
with app.server.test_request_context("/"):
app._setup_server()
Screenshots
Not applicable — both reproductions are deterministic and print their result.
- Lenguaje dominante
- Python
- Estrellas
- 24.4k
- Forks
- 2.3k
- Merge medio
- 2 d 7 h
- PR fusionados (30 d)
- 13
Guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de plotly/dash
-
good first issue P3 size: 1 task
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
-
enhancement P3 size: 10+
-
P2 size: 1 task
-
enhancement P3 size: 1
Dificultad 3/5 1-2 días Aptitud para principiantes 72/100
-
P3 size: 1 task
Dificultad 3/5 1-2 días Aptitud para principiantes 63/100
Todos los issues de plotly/dash
Issues similares
-
link-check link-check:sphinx-theme
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
qgis/QGIS-Documentation#11275 ·
-
bug priority:normal ready-for-dev
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
OpenHands/extensions#626 · 1 comentario ·
-
Change observation tooltip text Abierto
Dificultad 1/5 Menos de una hora Aptitud para principiantes 90/100
CSCfi/sd-search-api#39 ·
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 90/100