python / python/cpython

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

Đang mở
#149,728 3 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

interpreter-core topic-importlib type-bug
Ngôn ngữ chính
Python
Star
77.2k
Fork
35.9k
Chỉ số merge pull request
Chỉ số pull request đang chờ

Mô tả

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu trong Lib/importlib/_bootstrap.py và lần theo _load_unlocked, _find_and_load_unlocked, _load và _builtin_from_name cùng với reproducer được cung cấp. Xác minh rằng việc import submodule lazy không còn đệ quy trong khoảng thời gian giữa các thread và rằng reproducer in ra OK, đồng thời vẫn giữ nguyên việc dọn dẹp khởi tạo cho các caller khác.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
backend
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Đình trệ
Độ rõ ràng
Đặc tả rõ ràng
Mức phù hợp với người mới
35/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.