plotly / plotly/dash

[BUG] Race condition in Dash._setup_server(): guard flag is set before the work it protects

Aperta
#3,971 4 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

bug P2 size: 5
Lingua principale
Python
Stelle
24.4k
Fork
2.3k
Merge medio
2g 7h
PR unite (30g)
13

Descrizione

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.

Guida per i contributori

Apri la guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Direzione di ricerca

Inizia in dash.py, in Dash.init e _setup_server(), concentrandoti sul setup guard, registered_paths, callback_map e sul pages guard in router_async e router_sync. Esegui gli script di riproduzione collegati con richieste concorrenti; il lavoro è completo quando l'inizializzazione è serializzata e le prime richieste concorrenti non osservano mai uno stato vuoto o parzialmente popolato.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
flask, python
Ambito
backend
Tipo di issue
Bug
Difficoltà
4/5
Tempo stimato
3-5 giorni
Stato di attività
Attiva
Chiarezza
Specificata chiaramente
Idoneità per principianti
45/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.