pybind / pybind/pybind11

[BUG]: Intermittent null internals_pp_manager::get_pp() during concurrent subinterpreter imports on Windows

Open
#6,174 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

triage
Dominant language
C++
Stars
18k
Forks
2.3k
Avg merge
5d 17h
Merged PRs (30d)
10

Description

Summary

The scheduled master CI intermittently reached a state that internals_pp_manager::get_pp() documents as impossible while importing a pybind11 module concurrently in multiple Python subinterpreters on Windows:

ImportError: get_internals: get_pp() returned nullptr

The failure occurred once during the existing stress test and the complete job passed when rerun. This issue records the expiring CI evidence and the relevant implementation history so the race can be investigated later; it is not yet a proven root cause or proposed fix.

CI evidence (workflow logs will expire)

The failed job otherwise reported 1,295 passed tests, 32 skipped tests, and this single failure. The rerun on 2026-09-12 used the same commit, OS image, Python, compiler, configuration, and test code; it passed the Python tests and the rest of the job. That makes this look scheduling-sensitive rather than environment-version-sensitive.

Environment preserved from the log

  • GitHub Actions windows-latest (Windows Server 2025, 10.0.26100)
  • Runner image: windows-2025-vs2026, version 20260907.229.1
  • CPython 3.14.7, regular GIL build, x64
  • Debug configuration, C++20
  • Visual Studio 18 2026
  • MSVC 19.51.36256.0; toolset executable under MSVC 14.51.36231
  • CMake 4.4.3
  • pytest 9.1.1, pluggy 1.6.0, NumPy 2.5.3
  • PYBIND11_INTERNALS_VERSION=12
  • PYBIND11_SIMPLE_GIL_MANAGEMENT disabled

Durable reproduction information

The exact test and helper at the failing commit are permanently available here:

The test is already a meaningful stress test, rather than a single opportunistic import:

  1. check_script_success_in_subprocess() runs its subprocess eight times by default.
  2. Each subprocess creates an InterpreterPoolExecutor(max_workers=16).
  3. It submits 32 calls which concurrently import mod_per_interpreter_gil_with_singleton and inspect objects registered in its singleton. The module is explicitly declared with py::multiple_interpreters::per_interpreter_gil().
  4. The pytest locals showed the helper loop variable as _ = 3, so three complete subprocess repetitions succeeded and the fourth failed.

The core of each subprocess is:

from concurrent.futures import InterpreterPoolExecutor, as_completed

with InterpreterPoolExecutor(max_workers=16) as executor:
    futures = [executor.submit(test) for _ in range(32)]
    for future in as_completed(futures):
        future.result()

Here test() imports mod_per_interpreter_gil_with_singleton; the import failed in one worker with the diagnostic above. A useful initial reproduction approach is therefore to build the normal test modules on 64-bit Windows with CPython 3.14 and repeatedly run only:

test_multiple_interpreters.py::test_import_in_subinterpreter_concurrently

Increasing the helper's rerun value provides a straightforward way to raise the stress level without changing the concurrency pattern that produced the failure.

Why this is significant

At the failing commit, get_pp() is explicitly documented with "Will never return nullptr":

The null guard was added in #6018 as diagnostic hardening motivated by the unexplained Windows crashes in #5993. It turned this occurrence into a useful ImportError; without the guard, dereferencing the null pointer could instead have crashed. The context in #5993 was different, so this issue should not assume the two problems have the same cause.

This also overlaps directly with #5947, "Fix concurrency consistency for internals_pp_manager under multiple-interpreters." During development of that PR, commit 4ef8b0cc587a5b2f52bc0b06edda7f9ced268f4f disabled the manager's caches and always retrieved the state-dictionary entry. That experiment was reverted in 49952a82f8117c5f5e196e50e6e69a397d7c7372 before the final solution was merged. Repeating that experiment is a useful diagnostic comparison, although it is not necessarily the final design.

Initial code analysis and a leading hypothesis

The following is a plausible race to investigate, not a demonstrated root cause.

At the failing commit, ensure_internals() does this in order:

get_internals_pp_manager().unref();
if (PyInterpreterState_Get() != PyInterpreterState_Main()) {
    has_seen_non_main_interpreter() = true;
}
get_internals();

Before has_seen_non_main_interpreter() becomes true, get_pp() and unref() both use the shared, non-atomic internals_singleton_pp_ member. With independent interpreter GILs, two module-initialization threads may execute those paths concurrently. The one-way mode transition is not visibly synchronized with access to that raw shared member. One possible interleaving is:

  1. Thread A observes has_seen_non_main_interpreter() == false in get_pp() and prepares to return internals_singleton_pp_.
  2. A non-main-interpreter thread B enters ensure_internals(), still observes the flag as false in unref(), and clears internals_singleton_pp_.
  3. Thread A returns the now-null shared member.

Publishing has_seen_non_main_interpreter() = true before calling unref() in a non-main interpreter would cause that unref() to clear only the thread-local cache instead of the shared singleton. That is a focused first experiment, but it needs Windows stress testing and an audit of initialization/finalization behavior before being considered a fix.

A second path worth auditing is the thread-local cache update in get_pp():

last_istate_tls() = tstate->interp;
internals_p_tls() = get_or_create_pp_in_state_dict();

If the second operation can throw after the interpreter identity has been cached, a later call in the same interpreter could skip cache initialization and return a null internals_p_tls(). The observed log did not show an earlier underlying exception, so this is only another invariant to check.

Suggested investigation sequence

  1. Reproduce with the existing focused test on the recorded Windows/Python environment and a larger subprocess repetition count.
  2. Test publishing has_seen_non_main_interpreter() before unref() as a narrowly targeted experiment.
  3. Compare against #5947's always-read-the-state-dictionary implementation to determine whether the singleton/TLS cache transition is essential to the failure.
  4. Add temporary tracing or assertions around the flag, current interpreter, last_istate_tls(), internals_p_tls(), and internals_singleton_pp_ if reproduction remains intermittent.
  5. Keep the existing test strict. Its musllinux xfail covers a different known failure and should not be generalized to Windows.

Regression status is unknown: this was observed once on current master, and the exact rerun passed.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with tests/test_multiple_interpreters.py::test_import_in_subinterpreter_concurrently and repeatedly run it on 64-bit Windows with CPython 3.14; increase the helper rerun count if needed. Then inspect include/pybind11/detail/internals.h, especially ensure_internals() and internals_pp_manager, and compare the implementation discussed in #5947. Done means identifying and validating the race or invariant failure, then demonstrating that the focused stress test remains reliable.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, github-actions, python
Domain
backend, testing-qa
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.