[BUG] Race condition in Dash._setup_server(): guard flag is set before the work it protects
还没有人认领这个 Issue。
评估
调研方向
从 dash.py 中的 Dash.init 和 _setup_server() 开始,重点关注 setup guard、registered_paths、callback_map,以及 router_async 和 router_sync 中的 pages guard。使用并发请求运行已链接的复现脚本;当初始化被串行化,并发的首次请求始终不会观察到空状态或部分填充的状态时,即表示完成。
由索引模型根据 Issue 内容生成。
描述
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.
- 主要语言
- Python
- 星标
- 24.4k
- 派生
- 2.3k
- 平均合并
- 1 天 19 小时
- 30 天内合并 PR
- 19
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
plotly/dash 的其他 Issue
-
good first issue P3 size: 1 task
难度 2/5 1-3 小时 新手友好度 68/100
-
enhancement P2 size: 5
难度 5/5 一周以上 新手友好度 45/100
-
enhancement P3 size: 10+
-
P2 size: 1 task
-
enhancement P3 size: 1
难度 3/5 1-2 天 新手友好度 72/100
相似的 Issue
-
essnmx good first issue
难度 1/5 1 小时以内 新手友好度 95/100
-
难度 2/5 1-3 小时 新手友好度 65/100
syfoud/Simulated_Scepter#174 ·
-
难度 2/5 1-3 小时 新手友好度 75/100
Giskard-AI/giskard-oss#2840 · 1 条评论 ·
-
A claim comment carrying the issue number is silently declined while the workflow reports success 未关闭area: repo bug perceived difficulty: 2
难度 2/5 1-3 小时 新手友好度 70/100
-
难度 2/5 1-3 小时 新手友好度 75/100
yeti-platform/yeti#1380 ·