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 摘要。