Skip to content

Fix memory and thread-safety issues for free-threaded (GIL=0) Python - #108

Merged
Qubitium merged 1 commit into
mainfrom
fix/free-threading-memory-thread-safety
Aug 24, 2026
Merged

Fix memory and thread-safety issues for free-threaded (GIL=0) Python#108
Qubitium merged 1 commit into
mainfrom
fix/free-threading-memory-thread-safety

Conversation

@Qubitium

Copy link
Copy Markdown
Contributor

Summary

Memory- and thread-safety audit of the C extension and Python layer, focused on free-threaded (GIL=0) CPython. Every fix below has a concrete failure interleaving; the highest-severity items are use-after-free / heap-corruption class.

C extension

Area Bug Fix
FindIter (pcre2.c) Iterator lock existed only on Py_GIL_DISABLED builds, but GIL builds release the GIL around pcre2_match for subjects > 256 KiB — two threads sharing one finditer iterator could run pcre2_match concurrently on the same match_data → torn ovector / heap corruption. The raw PyThread lock could also deadlock a free-threaded stop-the-world pause (attached waiter) Lock on every build: PyMutex on 3.13+ (parks GC-safely); on older GIL builds the lock is acquired with the GIL released
Match-context cache (cache.c) match_context_cache_acquire returned the thread-local context while it stayed resident in the slot. Interpreter finalization of a daemon thread, a reentrant acquire from a GC finalizer, or a context-cache toggle flip could free/mutate a context mid-pcre2_match → UAF, double free, or silent offset-limit cross-contamination (matches beyond endpos) Exclusive ownership: detach on acquire, re-store (or free) on release — same protocol match_data already used
Pattern_substitute pcre2_substitute executes JIT code but skipped the jit_guard serial lock that every other JIT site takes (PYPCRE_FORCE_JIT_LOCK platforms) Guard the call when the pattern is JIT-enabled
module_exec error path A failed re-exec (importlib.reload) ran cache_teardown / pcre_error_teardown / jit_support_teardown / pcre_memory_teardown, freeing locks, TSS keys and exception types while live threads from the previous successful import use them → NULL-deref in raise_pcre_error, UAF on freed locks Teardowns run only if the very first initialization fails; cache_initialize is idempotent; context-cache env toggle applied only on first init
memory.c (1) Spinlock waiters cleared a flag they didn't own → two threads could run the allocator initializer concurrently and tear the current_alloc/current_free pair (jemalloc malloc paired with PyMem_Free). (2) Teardown dlclosed jemalloc/tcmalloc and reset current_free while allocations (Match ovectors) were still live → free-with-wrong-allocator / call into unmapped code. (3) pcre_free lazily re-initialized the allocator, possibly selecting a different one than produced the pointer Proper spin-wait (only the owner clears); allocator selection is process-lifetime irreversible; no re-init in pcre_free
Global pattern cache (pattern_cache.c) Raw non-reentrant PyThread lock held across PyDict_SetItem/eviction DECREFs → free-threaded stop-the-world deadlock (attached waiter) and self-deadlock when a GC finalizer re-enters pcre.compile; mode flag was published before the lock existed; eviction deleted from the order list before the map, letting a failed map delete strand an unevictable entry Critical sections on the map dict (STW-safe, reentrancy-safe); PyDict_GetItemRef lookups on 3.13+; publish mode after containers exist; map-first eviction; clear empties containers in place
Match_expand bytearray template scanned via PyByteArray_AS_STRING with no pin — concurrent ba.clear()/resize frees the buffer mid-memchr → UAF read Snapshot to bytes under the object's critical section
GC Match/FindIter hold arbitrary user objects (owner arg) and Pattern can hold str/bytes subclasses, but none had tp_traverse → reference cycles leaked permanently Py_TPFLAGS_HAVE_GC + traverse/clear for all three types
error.c raise_pcre_error INCREF'd a borrowed dict entry (free-threaded race window) and could INCREF PcreError == NULL PyDict_GetItemRef on 3.13+, NULL check
atomic_compat.h ATOMIC_VAR_INIT was removed in C23 — gcc 15+/clang 19+ fail to build Fallback definition (also fixed the build in this PR's own testing)

Python layer

  • parallel_map nested-call deadlock: a task running on a pcre-worker thread that itself calls parallel_map submitted chunks to the same pool and blocked on their futures; with all workers doing this, everyone waits for chunks only those workers can run. Nested calls now execute inline (thread-local worker flag).
  • cache.py epoch re-check: the post-compile check compared _THREAD_LOCAL.epoch — a value only the current thread writes — so it could never detect a concurrent clear_cache(); a stale compiled pattern survived a process-wide clear. Now re-reads the global epoch.
  • threads.py: the macOS sysctl subprocess probe ran while holding the process-wide pool lock, stalling every thread touching the pool. Now probed outside the lock with atomic publication.

Testing

  • CPython 3.14.7 free-threaded (sys._is_gil_enabled() == False) and 3.14.7 GIL builds, Linux x86-64, linked against system PCRE2 10.46.
  • Full pytest suite: 838 passed, 11 skipped, 545 subtests passed (benchmark module excluded).
  • Targeted multithreaded stress (8 threads each): shared finditer iterator over a >800 KB subject; match/findall/sub/split churn on one pattern (context/match-data cache reuse); concurrent compile + clear_cache; 1000 owner-reference cycles collected by GC; Match.expand on a bytearray mutated concurrently by another thread. No crashes, no errors, no deadlocks.

🤖 Generated with Claude Code

C extension:
- FindIter: serialize iternext on every build. GIL builds release the GIL
  around pcre2_match for large subjects, so two threads sharing one iterator
  could run pcre2_match concurrently on the same match_data (heap corruption).
  Uses PyMutex on 3.13+ (parks GC-safely, no stop-the-world deadlock); on
  older GIL builds the PyThread lock is acquired with the GIL released.
- match context cache: hand out thread-local contexts with exclusive
  ownership (detach on acquire, re-store on release). Previously the pointer
  stayed resident in the ThreadCacheState slot, so interpreter finalization,
  reentrant acquires from GC finalizers, or a context-cache toggle could
  free or mutate a context still in use (use-after-free / double free /
  offset-limit cross-contamination).
- Pattern_substitute: take the jit_guard serial lock around pcre2_substitute
  when the pattern is JIT-compiled (PYPCRE_FORCE_JIT_LOCK platforms), matching
  every other JIT execution site.
- module_exec: never run global teardowns (error types, caches, jit lock,
  allocator) on a failed re-exec after a successful init; they free locks,
  TSS keys and exception types that live threads are using. cache_initialize
  is now idempotent and the context-cache toggle is applied only on first init.
- memory.c: spinlock waiters no longer clear a flag they do not own (two
  threads could both run the allocator initializer and tear the
  current_alloc/current_free pair); allocator selection is now irreversible —
  teardown no longer dlcloses the backing library or resets current_free
  while allocations from it are still live; pcre_free no longer lazily
  re-initializes (which could select a different allocator than the one that
  produced the pointer).
- pattern cache (global mode): replace the raw PyThread lock with critical
  sections on the map dict. The raw lock deadlocked free-threaded
  stop-the-world pauses (attached waiter) and self-deadlocked when a GC
  finalizer re-entered pcre.compile; lookups use PyDict_GetItemRef on 3.13+.
  Publish global mode only after the shared containers exist; evict from the
  map before the order list so a failed delete cannot strand an unevictable
  entry; clear_cache empties containers in place instead of freeing them.
- Match_expand: snapshot bytearray templates under the object's critical
  section before scanning the raw buffer (a concurrent resize could free the
  buffer mid-scan).
- GC support for Pattern, Match and FindIter (tp_traverse/tp_clear +
  PyObject_GC_New/Track). Match/FindIter hold arbitrary user objects via the
  owner argument, so reference cycles through them previously leaked
  permanently.
- error.c: NULL-check PcreError and use PyDict_GetItemRef (3.13+) so
  raise_pcre_error cannot INCREF through a borrowed reference racing a dict
  mutation.
- atomic_compat.h: define ATOMIC_VAR_INIT fallback (C17 deprecated it,
  C23 removed it; gcc 15+/clang 19+ no longer provide it — fixes the build).

Python layer:
- parallel_map: nested calls from inside a pool worker now run inline. All
  workers blocking on chunks that can only run on those same workers was a
  permanent deadlock.
- cache: the post-compile epoch re-check now re-reads the global epoch
  (it previously compared a thread-local value only the current thread
  writes, so a concurrent clear_cache() could leave stale patterns cached).
- threads: run the macOS sysctl probe outside the process-wide pool lock.

Tested on CPython 3.14.7 free-threaded (GIL=0 at runtime) and 3.14.7 GIL
builds: full pytest suite plus targeted multithreaded stress for shared
finditer iterators, context-cache churn, concurrent compile/clear_cache,
owner-reference cycles, and concurrent bytearray template mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Qubitium
Qubitium merged commit 9a52823 into main Aug 24, 2026
19 checks passed
Qubitium added a commit that referenced this pull request Aug 24, 2026
…ep (#109)

Follow-up to #108. A clean re-audit of the merged tree (fresh-eyes review
of pcre2.c, deep pass over the helper files, and an adversarial review of
the #108 diff itself, which found no regressions) surfaced the following.

C extension:
- util.c utf8_index_to_offset: the 8-byte chunked starter scan could stop
  with the returned offset pointing at the continuation bytes of a
  character whose starter was counted in the previous chunk (chunk
  boundary inside a multi-byte character near the string tail).  str
  subjects always run with PCRE2_NO_UTF_CHECK, so pcre2_match received a
  mid-codepoint start offset / exec length — documented undefined
  behavior.  Observable today: pcre.compile('.').search('𐍈a𐍈b', 3)
  returned span (3, 3) and .group() raised UnicodeDecodeError.  The tail
  scan now always skips trailing continuation bytes.
- Pattern_execute: the interpreter fallback ran on !pattern_jit_get(self),
  a pattern-global flag, instead of whether THIS call's JIT attempt
  produced a result.  Any call that skips JIT locally while the global
  flag stays set would run neither engine and convert the uninitialized
  match_data (rc == 0) into a Match with garbage offsets (crash on
  .group()).  Latent before; reachable once the partial-range JIT skip
  below was added.  Now tracked with a per-call flag.
- First-literal fast path: caselessness introduced by non-leading inline
  groups — (?i:abc), ((?i)abc) — is invisible to pattern_info, so the
  memchr/first-byte prescan filtered on one case only and
  pcre.compile('(?i:abc)').search('ABC') returned None.  The prescan now
  accepts both cases of an ASCII letter (still a pure filter; non-ASCII
  lead bytes are only reported by PCRE2 when shared by all case variants).
- pcre2_jit_match performs none of the UTF validity checks pcre2_match
  does, silently bypassing the module's "leave PCRE2_NO_UTF_CHECK unset so
  PCRE2 validates partial bytes ranges" invariant: a mid-character pos on
  a UTF bytes subject returned None under JIT where the interpreter raises
  PcreErrorBadutfoffset (and executed JIT code on malformed boundaries —
  documented UB).  Partial ranges of UTF bytes subjects now take the
  interpreter path (Pattern_execute, findall, finditer).
- Caller-supplied PCRE2_NO_UTF_CHECK in the options argument is now
  masked out (match/search/fullmatch, findall, finditer).  It let Python
  code trigger the same documented UB with a mid-character pos; the module
  re-adds the flag itself exactly when the range is validated.
- Free-threaded builds now detach the thread state around large PCRE2
  calls (same 256 KiB threshold as GIL builds).  Previously a thread
  inside a long pcre2_match stayed attached and stalled every
  stop-the-world pause (gc.collect() in any thread) for the duration of
  the match.
- Match_expand: the expand_match_template helper was fetched as a
  borrowed dict reference and INCREF'd afterwards — a concurrent rebind
  of the module attribute could free it in between (free-threaded UAF).
  Now fetched as a strong reference via PyObject_GetAttrString.
- Pattern_substitute: key the jit_guard on PCRE2_INFO_JITSIZE as well as
  the acquired jit stack — pcre2_substitute executes JIT-compiled code
  even after the module's jit flag was cleared by a BADOPTION downgrade.
- module_exec: latch jit_support_initialize and pattern_cache_initialize
  on first init.  A re-exec with changed env vars could materialize the
  jit serial lock mid-flight (jit_guard_release then releases a lock that
  jit_guard_acquire never took, permanently breaking JIT serialization)
  or flip the pattern-cache mode while threads hold the global map.
- cache_initialize: stop resetting context_cache_enabled — it silently
  clobbered the PYPCRE_DISABLE_CONTEXT_CACHE env toggle applied by
  module_exec just before (the knob previously had no effect).
- string_helpers.c: PyErr_Format does not support the %.*s dynamic
  precision spec — an out-of-range \U escape raised SystemError instead
  of PcreError.
- atomic_compat.h: the MSVC-fallback _Generic maps listed volatile
  uint32_t* and volatile size_t* — identical types on 32-bit Windows, a
  compile-time constraint violation.  The size_t associations now exist
  only where size_t is a distinct 64-bit type.

Python layer:
- threads.py: the macOS sysctl CPU probe could still run while holding
  the process-wide pool lock via ensure_thread_pool /
  _thread_pool_submission / get_thread_pool_size (the previous fix only
  covered configure_thread_pool); the probe is now resolved before the
  lock in all callers.

Tested on CPython 3.14.7 free-threaded (GIL=0 at runtime) and 3.14.7 GIL
builds: full pytest suite, targeted regressions for every fix above
(differential vs re where applicable), and the multithreaded stress
suite from #108.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant