python / python/cpython

Free-threaded importlib race recurses on lazy-submodule `__getattr__`

未关闭
#149,728 3 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

interpreter-core topic-importlib type-bug
主要语言
Python
星标
77.2k
派生
35.9k
PR 合并指标
PR 指标待抓取

描述

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) sets spec._initializing = False at line 930.
  • _find_and_load_unlocked (line 1263) calls _load_unlocked, then at line 1313 does setattr(parent_module, child, module).

There is a window between line 930 and line 1313 where:

  • sys.modules[name] is set,
  • spec._initializing == False, but
  • parent.__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 = False from the success path of _load_unlocked (was at line 930).
  • Clear it at the end of _find_and_load_unlocked, after setattr(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 in try: ... finally: spec._initializing = False to preserve current behavior.
CPython versions tested on:

3.14

Operating systems tested on:

macOS

Linked PRs
  • gh-149729

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从 Lib/importlib/_bootstrap.py 开始,结合提供的复现程序跟踪 _load_unlocked、_find_and_load_unlocked、_load 和 _builtin_from_name。验证惰性子模块导入在跨线程窗口期间不再递归,并且复现程序会打印 OK,同时为其他调用方保留初始化清理逻辑。

由索引模型根据 Issue 内容生成。

评估

技术栈
python
领域
backend
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
停滞
描述清晰度
描述清楚
新手友好度
35/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。