Skip to content

gh-149728: Fix free-threaded race in importlib lazy-submodule fast path#149729

Open
SwayamInSync wants to merge 2 commits into
python:mainfrom
SwayamInSync:fix-lazyimport-freethreading-race
Open

gh-149728: Fix free-threaded race in importlib lazy-submodule fast path#149729
SwayamInSync wants to merge 2 commits into
python:mainfrom
SwayamInSync:fix-lazyimport-freethreading-race

Conversation

@SwayamInSync
Copy link
Copy Markdown

@SwayamInSync SwayamInSync commented May 12, 2026

Fixes #149728.

Cause

_load_unlocked clears spec._initializing at the end of its body, before _find_and_load_unlocked runs setattr(parent_module, child, module). Between those two events, sys.modules[name] is set and _initializing == False, but parent.__dict__[child] is still missing. The fast path in _find_and_load returns the module without taking the import lock once it sees _initializing == False, so under free-threaded CPython a second thread can observe this window. IMPORT_FROM 'child' on that thread does getattr(parent, 'child'), falls into a lazy __getattr__, runs the same import parent.child as child line, fast-paths again, and recurses to RecursionError. See the linked issue for the full walkthrough and reproducer.

Change

Keep spec._initializing == True until after the parent setattr in _find_and_load_unlocked:

  • _load_unlocked no longer clears _initializing on the success path. Failure paths still clear it.
  • _find_and_load_unlocked clears _initializing in a finally block after setattr(parent_module, child, module) and _imp._set_lazy_attributes.
  • The two other callers of _load_unlocked (_load and _builtin_from_name) have no parent setattr, so they clear _initializing in a local finally.

This preserves the invariant the fast path needs: _initializing == False implies the module is reachable via getattr(parent_module, child).

Test

Lib/test/test_importlib/test_threaded_import.py::ThreadedImportTests::test_lazy_submodule_getattr_no_recursion widens the natural microsecond race window with one threading.Event and verifies that an observer thread does not recurse on the lazy __getattr__. Fails on the unpatched interpreter, passes on this branch. Full test_importlib suite remains green (1220/1220).

Notes

  • Tested on macOS arm64 with both the free-threaded build (3.16.0a0 from this branch) and the system GIL build (3.14.3). The bug reproduces on both builds under the deterministic shim, confirming the code path itself is build-independent; free-threading just makes the window naturally observable.

@bedevere-app
Copy link
Copy Markdown

bedevere-app Bot commented May 12, 2026

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@python-cla-bot
Copy link
Copy Markdown

python-cla-bot Bot commented May 12, 2026

All commit authors signed the Contributor License Agreement.

CLA signed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Free-threaded importlib race recurses on lazy-submodule __getattr__

1 participant