Skip to content

Pin PyThreadStates so native extensions can cache them per thread#137

Merged
jhonabreul merged 2 commits into
QuantConnect:masterfrom
Martin-Molinero:fix-gilstate-thread-state-pinning
Jul 13, 2026
Merged

Pin PyThreadStates so native extensions can cache them per thread#137
jhonabreul merged 2 commits into
QuantConnect:masterfrom
Martin-Molinero:fix-gilstate-thread-state-pinning

Conversation

@Martin-Molinero

Copy link
Copy Markdown
Member

Description

Py.GIL() acquires the GIL through PyGILState_Ensure/PyGILState_Release. For a .NET thread, the outermost scope creates a fresh PyThreadState and deletes it on dispose. Native extensions built with pybind11 — matplotlib >= 3.10 _path/ft2font, several scipy submodules, PyTorch — cache the first PyThreadState* they see for each OS thread in pybind11's internals TLS and never invalidate it (pybind/pybind11#2888). Once a Py.GIL() scope deletes the thread state, that cached pointer dangles, and the next pybind11 gil_scoped_acquire on the same thread calls PyEval_RestoreThread on freed memory: 0xC0000005 access violation (or 0xc0000374 heap corruption, depending on heap reuse).

This is the root cause of QuantConnect/Lean#9203 (ReportChartTests.ExposureReportWorksForEverySecurityType crashing the test host): the ChartReportElement constructor's Py.GIL() scope imports matplotlib (pybind11 caches that scope's thread state), the scope's dispose frees it, and the first chart render (matshow_path.update_path_extents / ft2font text rendering) restores the dangling pointer. Verified from crash dumps: fault at PyEval_RestoreThread+0x7A reading tstate->interp->runtime with interp == NULL, and the restored pointer matched a thread state created and deleted in an earlier GIL scope whose memory had been reused for string data.

Fix

Pin each thread's PyThreadState once created: GILState issues one extra, never-released PyGILState_Ensure per thread per runtime run, keeping the gilstate counter >= 1 so PyGILState_Release never deletes the thread state. This matches how CPython itself treats thread states it binds via _PyGILState_NoteThreadState (counter starts at 1, never auto-deleted). The pin is keyed on Runtime.GetRun() so engine Initialize/Shutdown cycles re-pin correctly, and Py_Finalize reclaims all surviving thread states at shutdown.

Trade-offs, measured:

  • The extra Ensure runs while the GIL is already held, so it is a bare counter increment — no locking, no change to when the GIL is taken or released. Microbenchmark (200k Py.GIL() scopes on a worker thread): 0.212 -> 0.209 us/scope, i.e. within noise.
  • One PyThreadState (~1 KB) is intentionally kept per .NET thread that ever used Python, until engine shutdown — the same lifetime CPython gives Python-created threads.

Regression test

TestGILState.ThreadStateIsPreservedBetweenGILScopes: threading.local values live in the thread state dictionary, so a value stored in one Py.GIL() scope survives into a second scope on the same thread only if the thread state was not deleted — fails on master, passes with the fix (no pybind11 dependency needed).

Repro validation on Lean master (Windows, Python 3.11.9, matplotlib 3.10.1): the single-test crash reproduced 3/3 before the fix; with it, 5/5 repro runs, the full ReportChartTests fixture (66 tests) and PandasConverterTests (1216 tests) all pass.

Fixes QuantConnect/Lean#9203

🤖 Generated with Claude Code

Martin-Molinero and others added 2 commits July 13, 2026 12:45
Py.GIL() acquires the GIL via PyGILState_Ensure/Release; the outermost
scope on a .NET thread creates a fresh PyThreadState and deletes it on
dispose. pybind11-based extensions (matplotlib >= 3.10 _path/ft2font,
scipy, PyTorch) cache the first PyThreadState* they see per OS thread
in pybind11 internals TLS and never invalidate it, so once a scope
deletes the thread state the next native GIL acquire on that thread
restores a dangling pointer: 0xC0000005 access violation or heap
corruption. Root cause of QuantConnect/Lean#9203 (ReportChartTests
crashing the test host on the first matplotlib render).

Pin each thread state with one extra never-released PyGILState_Ensure
per thread per runtime run, keeping the gilstate counter >= 1 so the
thread state lives until engine shutdown - the same lifetime CPython
gives thread states it binds itself. The extra Ensure runs with the
GIL already held (a bare counter increment): GIL acquire/release
timing is unchanged and empty-scope microbenchmarks are within noise
(0.212 -> 0.209 us/scope).

Regression test: threading.local values live in the thread state dict,
so a value stored in one Py.GIL scope survives a second scope on the
same thread only if the thread state was not deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump package <Version>, AssemblyVersion/AssemblyFileVersion and the
perf-test baseline reference to 2.0.62.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jhonabreul jhonabreul merged commit 4ec6ca1 into QuantConnect:master Jul 13, 2026
8 checks passed
jhonabreul added a commit that referenced this pull request Jul 14, 2026
* Clear pending Python error when a Try-style conversion fails

PyObject.TryAsManagedObject converted with setError: true and returned
false on failure, leaving the Python error indicator set on the thread
with no one left to surface it. Callers of TryAs/TryAsManagedObject
only observe the boolean, so the stale error survived on the thread
state and was thrown by the next unrelated Python call that checks the
error indicator.

This was previously masked because the outermost Py.GIL scope deleted
the thread state (and its pending error) on dispose; since thread
states are pinned for the lifetime of the run (#137), the leaked error
persists and poisons subsequent calls on the same thread - e.g. Lean's
PythonUtilTests failing with "int() argument must be a string, a
bytes-like object or a real number, not 'function'" leaked by an
earlier test that exercises a failed TryAs<int> on a function.

Convert with setError: false in TryAsManagedObject and clear any error
a conversion sub-path may have left pending before returning false.
AsManagedObject keeps converting with setError: true so the fetched
error still becomes the InvalidCastException cause, which also
consumes the indicator.

* Update version to 2.0.63

Bump package <Version>, AssemblyVersion/AssemblyFileVersion and the
perf-test baseline reference to 2.0.63.

* Clear leaked Python errors in converter collection and label paths

Several Converter failure paths could leave a Python error pending even
when called with setError: false, relying on the caller to clean up:

- ToList did not handle a failed PyObject_GetIter: the raised TypeError
  survived the call (and the iterator reference leaked). Handle it like
  ToArray does, disposing the reference and clearing when not setting
  errors.
- MakeList treated a null PyIter_Next result as normal exhaustion, but
  null also means the iterator raised: the conversion now fails instead
  of returning a silently truncated list, clearing the error when not
  setting errors and leaving it pending for the caller when setting
  errors. The swallowed CLR-exception catch and the element-conversion
  failure path also clear when not setting errors, since some element
  conversion paths (failed implicit operators, deleted types) raise
  regardless of setError to enrich MethodBinder's no-match message.
- The type_error and overflow labels now clear pending errors when not
  setting errors, mirroring what convert_error already did, so a
  probing sub-path that raised before jumping cannot leak.

The unconditional raises in the implicit-conversion catches are kept:
MethodBinder.Invoke deliberately surfaces a pending error as the
binding failure reason instead of its generic no-match message, and
Converter.TryAsManagedObject clears at the API boundary.

* Clear rejected overload probe errors when a method bind succeeds

MethodBinder probes each overload candidate with setError: false, but a
probe can still raise: the implicit-conversion catches in
Converter.ToManagedValue raise unconditionally so that Invoke can
surface the specific cause (e.g. a throwing implicit operator) as the
bind-failure reason instead of the generic no-match message.

When the raising candidate was probed after the candidate that
ultimately matched, nothing cleared the indicator before the method ran
and the otherwise successful call failed with "SystemError: ...
returned a result with an error set". Errors raised by earlier
candidates were incidentally wiped by the per-candidate
PyObject_Type/Exceptions.Clear type check, which is why the leak needs
this specific overload ordering: an exact-class-match overload
(precedence 40) binds first, then a string overload (precedence 50) is
probed and raises.

Clear any pending probe error in Bind once an overload has matched. The
bind-failure path is untouched, so a pending error is still surfaced as
the failure reason when nothing matches (ImplicitConversionErrorHandling).
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.

ExposureReportWorksForEverySecurityType test causing crash

2 participants