Free-threaded importlib race recurses on lazy-submodule `__getattr__`
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 77.2k
- Forks
- 35.9k
- PR merge metrics
- PR metrics pending
Description
Bug report
Bug description:
Free-threaded CPython (cp3.14t), multiple threads first-touching a lazy submodule via the standard pattern
# pkg/__init__.py
def __getattr__(attr):
if attr == "sub":
import pkg.sub as sub
return sub
recurse to RecursionError: maximum recursion depth exceeded.
Origin
First observed in numpy-quaddtype's free-threaded CI: https://github.com/numpy/numpy-quaddtype/pull/88#issuecomment-4426320213. The traceback recurses through numpy/__init__.py:745's lazy __getattr__ (line import numpy.rec as rec). The same shape reproduces with any stdlib package that uses the lazy-submodule __getattr__ pattern.
Cause
In Lib/importlib/_bootstrap.py:
_load_unlocked(line 899) setsspec._initializing = Falseat line 930._find_and_load_unlocked(line 1263) calls_load_unlocked, then at line 1313 doessetattr(parent_module, child, module).
There is a window between line 930 and line 1313 where:
sys.modules[name]is set,spec._initializing == False, butparent.__dict__[child]is missing.
The fast path in _find_and_load (line 1334) returns the module without taking the lock once it sees sys.modules[name] populated and _initializing == False. A second thread that enters this fast path inside the window then runs IMPORT_FROM 'sub' → getattr(parent, 'sub') → falls into the package's lazy __getattr__ → executes the same import pkg.sub as sub line → fast-paths again → same missing attribute → recurses to RecursionError.
Reproducer
# python3.14t repro.py
import os, sys, threading, tempfile, importlib._bootstrap as _b, traceback
pkg = tempfile.mkdtemp()
os.makedirs(os.path.join(pkg, "lazypkg", "sub"))
with open(os.path.join(pkg, "lazypkg", "__init__.py"), "w") as f:
f.write(
"def __getattr__(attr):\n"
" if attr == 'sub':\n"
" import lazypkg.sub as sub\n"
" return sub\n"
" raise AttributeError(attr)\n"
)
with open(os.path.join(pkg, "lazypkg", "sub", "__init__.py"), "w") as f:
f.write("X = 42\n")
sys.path.insert(0, pkg)
gate = threading.Event()
done = threading.Event()
_orig = _b._find_and_load_unlocked
def widen(name, import_):
if name != "lazypkg.sub":
return _orig(name, import_)
parent = sys.modules["lazypkg"]
spec = _b._find_spec(name, parent.__path__)
parent.__spec__._uninitialized_submodules.append("sub")
try:
module = _b._load_unlocked(spec)
finally:
parent.__spec__._uninitialized_submodules.pop()
gate.set()
done.wait(timeout=5.0)
setattr(parent, "sub", module)
return module
_b._find_and_load_unlocked = widen
result = {}
def loader():
import lazypkg
lazypkg.sub
def observer():
gate.wait()
try:
import lazypkg
result["value"] = lazypkg.sub
except BaseException as e:
result["error"] = e
done.set()
ta = threading.Thread(target=loader)
tb = threading.Thread(target=observer)
tb.start(); ta.start(); ta.join(); tb.join()
if "error" in result:
traceback.print_exception(type(result["error"]), result["error"],
result["error"].__traceback__, limit=6)
sys.exit(1)
print("OK"); sys.exit(0)
The shim widens the natural µs gap to seconds with one threading.Event. Everything else (the lazy __getattr__, IMPORT_FROM, attribute lookup) is plain upstream Python. Reproduces on both the free-threaded build and the GIL build.
Proposed fix
Keep spec._initializing == True until after the parent setattr:
- Remove
spec._initializing = Falsefrom the success path of_load_unlocked(was at line 930). - Clear it at the end of
_find_and_load_unlocked, aftersetattr(parent_module, child, module)and_imp._set_lazy_attributes. - For the two other callers of
_load_unlocked(_load, the testing helper that doesn't attach to a parent;_builtin_from_name, for top-level builtins): they have no parent setattr, so wrap their call intry: ... finally: spec._initializing = Falseto preserve current behavior.
CPython versions tested on:
3.14
Operating systems tested on:
macOS
Linked PRs
- gh-149729
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in Lib/importlib/_bootstrap.py and trace _load_unlocked, _find_and_load_unlocked, _load, and _builtin_from_name alongside the supplied reproducer. Verify that the lazy submodule import no longer recurses during the inter-thread window and that the reproducer prints OK while preserving initialization cleanup for the other callers.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100