Skip to content

fix(subinterpreter): don't touch the thread state before create() attaches one - #6127

Merged
rwgk merged 1 commit into
pybind:masterfrom
ymwang78:fix/subinterpreter-create-without-thread-state
Aug 6, 2026
Merged

fix(subinterpreter): don't touch the thread state before create() attaches one#6127
rwgk merged 1 commit into
pybind:masterfrom
ymwang78:fix/subinterpreter-create-without-thread-state

Conversation

@ymwang78

@ymwang78 ymwang78 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

The problem

subinterpreter::create() documents that no GIL is required up front:

@note This function acquires (and then releases) the main interpreter GIL, but the main interpreter and its GIL are not required to be held prior to calling this function.

But its very first statement is error_scope err_scope; — i.e. PyErr_Fetch()before main_guard attaches a thread state:

static subinterpreter create(PyInterpreterConfig const &cfg) {

    error_scope err_scope;           // <-- PyErr_Fetch() with no tstate
    subinterpreter result;
    {
        // we must hold the main GIL in order to create a subinterpreter
        subinterpreter_scoped_activate main_guard(main());

With no current PyThreadState, PyErr_Fetch() reaches _PyErr_GetRaisedException(NULL), which reads tstate->current_exception off a null pointer, and the process dies (SIGSEGV; 0xC0000005 on Windows). ~error_scope is the mirror image: it calls PyErr_Restore() after main_guard has already swapped the thread state back away.

Two entirely ordinary situations reach create() with no thread state:

  • an embedder that ends its initialization with PyEval_SaveThread() — the documented way to hand the GIL back after Py_InitializeFromConfig();
  • any worker thread that has never touched Python.

That is how I ran into it. An embedding project calls PyEval_SaveThread() at the end of its setup, and every subsequent py::subinterpreter::create() from that thread crashed immediately. Instrumenting the call site:

tstate=0000000000000000        # right after PyEval_SaveThread()
tstate=00007FFC0E5EADA0        # after an explicit gil_scoped_acquire

Wrapping the call in py::gil_scoped_acquire works around it, but that contradicts the documented contract.

The existing tests never hit this: they all run under the py::scoped_interpreter guard{} in catch.cpp, which keeps the GIL held on the main thread for the whole run, so create() is only ever reached with a thread state already attached.

The fix

Move error_scope inside the main_guard scope, so it is constructed only once a thread state is attached.

The case error_scope exists for is unaffected. A caller that already holds the main GIL takes subinterpreter_scoped_activate's simple_gil_ fast path, which keeps the same thread state, so its pending error is still saved across Py_NewInterpreterFromConfig() and restored afterwards — exactly as before.

A caller sitting on some other interpreter never had its error indicator touched in the first place: everything inside the block runs on the main interpreter's thread state, and PyThreadState_Swap() does not move error indicators. Leaving that caller's exception pending across the call is therefore correct as well.

As a side benefit, the pybind11_fail("failed to create new sub-interpreter") path now unwinds in a safer order: ~error_scope runs while main_guard is still alive, rather than after the thread state has been swapped away (or, previously, when there may have been none at all).

The test

Create Subinterpreter without a thread state covers both entry points — a thread that dropped its thread state via gil_scoped_release, and a thread that never had one:

before after
new test alone EXIT=139 (SIGSEGV) [ OK ]
whole test_with_catch 33 cases, exit 0 34 cases, exit 0

Verified on Windows / MSVC 14.51 / CPython 3.13.14. clang-format --style=file is clean on both files.

Suggested changelog entry:

  • Fixed a crash in py::subinterpreter::create() when called without a current PyThreadState, which its documentation explicitly allows — for example from an embedder that ended initialization with PyEval_SaveThread(), or from a worker thread that has never touched Python. error_scope is now constructed after a thread state has been attached instead of before.

📚 Documentation preview 📚: https://pybind11--6127.org.readthedocs.build/

Copilot AI lite review requested due to automatic review settings August 6, 2026 00:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Ready to approve

The change is narrowly scoped, matches the documented API contract, and includes a targeted regression test covering the previously crashing scenarios.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Fixes a crash in py::subinterpreter::create() when called from a thread with no current PyThreadState (e.g., after PyEval_SaveThread() or from a never-before-Python worker thread), aligning behavior with the function’s documented contract.

Changes:

  • Construct error_scope only after subinterpreter_scoped_activate main_guard(main()) has attached a thread state, preventing PyErr_Fetch() / PyErr_Restore() from operating on a null tstate.
  • Improves unwinding order so ~error_scope runs while the main interpreter thread state is still active.
  • Adds a Catch2 regression test that exercises both “dropped tstate” and “never had a tstate” entry paths.
File summaries
File Description
include/pybind11/subinterpreter.h Moves error_scope inside the main_guard scope and documents why it must be constructed after a thread state is attached.
tests/test_with_catch/test_subinterpreter.cpp Adds a regression test ensuring subinterpreter::create() works when the calling thread has no PyThreadState.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread include/pybind11/subinterpreter.h Outdated
@henryiii

henryiii commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

We'll probably do it when we merge, but I'd prefer Linux style trailers (Assisted-by: ClaudeCode:claude-opus-5) instead of co-author. Claude is not a person so is not a co-author.

Comment thread include/pybind11/subinterpreter.h Outdated
…aches one

`subinterpreter::create()` documents that "the main interpreter and its GIL
are not required to be held prior to calling this function", but its first
statement is `error_scope err_scope;`, i.e. `PyErr_Fetch()`, before
`main_guard` attaches a thread state. With no current `PyThreadState`,
`PyErr_Fetch()` -> `_PyErr_GetRaisedException(NULL)` dereferences null and
the process dies (SIGSEGV; 0xC0000005 on Windows). `~error_scope` is the
mirror image: it calls `PyErr_Restore()` after `main_guard` has already
swapped the thread state back away.

Two ordinary situations reach `create()` with no thread state:

- an embedder that ends its initialization with `PyEval_SaveThread()`,
  which is the documented way to hand the GIL back after
  `Py_InitializeFromConfig()`;
- any worker thread that has never touched Python.

Existing tests never hit this because they all run under the
`py::scoped_interpreter guard{}` in catch.cpp, which keeps the GIL held on
the main thread for the whole run.

Move `error_scope` inside the `main_guard` scope. The case it exists for is
unaffected: a caller that already holds the main GIL takes
`subinterpreter_scoped_activate`'s `simple_gil_` fast path, which keeps the
same thread state, so its pending error is still saved across
`Py_NewInterpreterFromConfig()` and restored afterwards. A caller sitting on
some other interpreter never had its error indicator touched in the first
place, since everything inside the block runs on the main interpreter's
thread state and `PyThreadState_Swap()` does not move error indicators. It
also makes the `pybind11_fail()` path unwind in a safer order: `~error_scope`
now runs while `main_guard` is still alive.

Add "Create Subinterpreter without a thread state", covering both a thread
that dropped its thread state via `gil_scoped_release` and a thread that
never had one. It segfaults without the fix and passes with it.

Verified on Windows / MSVC 14.51 / CPython 3.13.14: test_with_catch goes
from 33 to 34 test cases, all passing.

Assisted-by: ClaudeCode:claude-opus-5
@ymwang78
ymwang78 force-pushed the fix/subinterpreter-create-without-thread-state branch from 4ed2765 to 3466888 Compare August 6, 2026 05:54
@ymwang78

ymwang78 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Fixed on my side, so no need to do it at merge time: the commit now ends with Assisted-by: ClaudeCode:claude-opus-5 instead of the co-author trailer, and I dropped the matching footer from the PR description.

@rwgk

rwgk commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

@ymwang78 thanks a lot for fixing this!

@rwgk
rwgk merged commit 05f6e6f into pybind:master Aug 6, 2026
78 checks passed
@github-actions github-actions Bot added the needs changelog Possibly needs a changelog entry label Aug 6, 2026
henryiii added a commit to henryiii/pybind11 that referenced this pull request Aug 6, 2026
henryiii added a commit that referenced this pull request Aug 6, 2026
* docs: add 3.1 entries to the changelog

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

* chore: prepare 3.1.0 release

Assisted-by: ClaudeCode:claude-opus-5

* [skip ci] Change release date to August 4, 2026

* docs: add #6127 entry and set release date to August 6, 2026

Assisted-by: ClaudeCode:claude-opus-5

---------

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Co-authored-by: Ralf W. Grosse-Kunstleve <rwgkio@gmail.com>
@henryiii henryiii removed the needs changelog Possibly needs a changelog entry label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants