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

Serialize Dash._setup_server under a lock and publish its flag last#3980
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

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

1 participant