Free-threaded importlib race recurses on lazy-submodule `__getattr__`
Nadie ha tomado este issue todavía.
- Lenguaje dominante
- Python
- Estrellas
- 77.2k
- Forks
- 35.9k
- Métricas de merge de PR
- Métricas de PR pendientes
Descripción
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
Guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Línea de trabajo
Comienza en Lib/importlib/_bootstrap.py y sigue _load_unlocked, _find_and_load_unlocked, _load y _builtin_from_name junto con el reproductor proporcionado. Verifica que la importación diferida del submódulo ya no entre en recursión durante la ventana entre hilos y que el reproductor imprima OK, preservando la limpieza de la inicialización para los demás llamadores.
Escrito por el modelo de indexación a partir del texto del issue.
Evaluación
- Stack tecnológico
- python
- Área
- backend
- Tipo de issue
- Error
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Estado de actividad
- Estancado
- Claridad
- Bien especificado
- Aptitud para principiantes
- 35/100