Skip to content

Serialize Dash._setup_server under a lock and publish its flag last - #3980

Open
mokashang wants to merge 1 commit into
plotly:devfrom
mokashang:fix/setup-server-race-3971
Open

mokashang wants to merge 1 commit into
plotly:devfrom
mokashang:fix/setup-server-race-3971

Conversation

@mokashang

Copy link
Copy Markdown

Contributor Checklist

  • I have run the tests locally and they passed. (refer to testing section in contributing)
  • I have added tests, or extended existing tests, to cover any new features or bugs fixed in this PR

optionals

  • I have added entry in the CHANGELOG.md

Reference issue

Closes #3971.

What does this implement/fix?

Dash._setup_server is a before_request hook that is meant to execute once per process. It sets its _got_first_request["setup_server"] guard flag before performing the work that flag protects: populating registered_paths via _generate_scripts_html, copying the GLOBAL_CALLBACK_MAP entries into callback_map, running background-callback validation, and so on. On a multi-threaded WSGI worker such as gunicorn -k gthread, waitress, or flask run --threaded, a second request can enter _setup_server between the "set flag" and "do the work" statements, see the flag already set, skip setup, and then read registered_paths and validate against callback_map while both are still empty. The visible symptom is the first burst of _dash-component-suites/<lib>/... bundle requests after a restart returning 500 with Error loading dependency. "<lib>" is not a registered library. Registered libraries are: [].

The fix:

  • Add a per-instance threading.Lock next to _got_first_request and wrap the setup body with double-checked locking: the fast-path early-return still fires without any lock cost once setup is done; a raced-in second thread waits on the lock, then sees the flag set by whichever thread won and returns without redoing the work.
  • Move the _got_first_request["setup_server"] = True publication to the very end of the guarded block so no other thread can observe it while any side effect is still in flight.

Additional information

Scope is deliberately narrow. The same TOCTOU pattern exists in the pages guards inside router_async / router_sync (if self._got_first_request["pages"]: return followed by self._got_first_request["pages"] = True); those are left for a follow-up because the async version has an await get_layouts() inside the section that needs guarding, which needs asyncio.Lock rather than the plain threading.Lock used here.

Regression test in tests/unit/test_setup_server_race.py slows _generate_scripts_html by 100ms and runs four concurrent _setup_server calls behind a threading.Barrier. Before the fix, three of the four threads returned from _setup_server with app.registered_paths still empty; with the fix all four see it populated. pylint tests/unit -d all -e C0410,C0413,W0109 --rcfile=.pylintrc and pylint dash --rcfile=.pylintrc both stay clean, and black dash tests --exclude 'metadata_test.py|node_modules' --check passes.

`_setup_server` runs as a `before_request` hook and sets its
`_got_first_request["setup_server"]` guard flag before performing the
work that flag protects (populating `registered_paths` via
`_generate_scripts_html`, `callback_map` via the `GLOBAL_CALLBACK_MAP`
copy, and so on). On a multi-threaded WSGI worker such as
`gunicorn -k gthread`, waitress, or `flask run --threaded`, a second
request arriving in that gap sees the flag already set, skips setup,
then reads `registered_paths` and validates against `callback_map`
while both are still empty, so component-bundle requests 500 with
`Error loading dependency. "<lib>" is not a registered library`.

The setup body now runs under a per-instance `threading.Lock` with a
double-checked read of the flag: a raced-in thread waits on the lock,
then sees the flag set by whichever thread won and returns without
redoing the work. The flag is only published once every side effect
has been applied, so no other thread can observe it prematurely.
Callers on the hot path after the first request pay no lock cost.

Adds a regression test in `tests/unit/` that reproduces the race by
slowing `_generate_scripts_html` and running several concurrent
`_setup_server` calls; before the fix three of four threads returned
with `registered_paths` still empty, after the fix all threads see it
populated.

Closes plotly#3971.
@sonarqubecloud

Copy link
Copy Markdown

@T4rk1n T4rk1n left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _setup_server fix is correct: flag published last under the lock, double-checked so the post-setup path stays lock-free, with a real regression test. But the same fix needs to land on the pages router in this PR.

router_sync / router_async (dash/dash.py:2737 and dash/dash.py:2658) are before_request hooks with the identical set-flag-before-work TOCTOU, so a raced request skips router registration and the app serves with the _ID_CONTENT callback unregistered and validation_layout unbuilt: the same class of first-burst-after-restart breakage, just in pages.

  • router_sync (dash/dash.py:2737): a straight mirror of this change. Wrap the body in threading.Lock double-checked locking and publish the flag last, same as _setup_server. No reason to defer it.
  • router_async (dash/dash.py:2658): needs an asyncio.Lock because of the await get_layouts() inside the guarded region (a threading.Lock held across the await would deadlock the loop), and on 3.9 that lock has to be lazily created inside the coroutine (before the first await) rather than at __init__.

One CHANGELOG nit inline. Fix the pages guards and this is good.

Comment thread CHANGELOG.md
- [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`).

### Fixed
- Fix `Dash._setup_server` publishing its "already done" guard flag before the setup work behind it had run. Under a multi-threaded WSGI worker such as `gunicorn -k gthread` a second request arriving mid-setup could observe the flag already set, skip setup, then read `registered_paths` / `callback_map` while they were still empty, causing the first burst of component bundle requests after a restart to 500 with `Error loading dependency. "<lib>" is not a registered library`. The setup body now runs under a lock and only publishes the flag after all work completes. Fixes [#3971](https://github.com/plotly/dash/issues/3971).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neighbor entries lead with the PR link ([#3944](...), [#3955](...)). Prefix this with [#3980](https://github.com/plotly/dash/pull/3980) to match. The Fixes #3971 at the end is the issue, not the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants