Fix memory and thread-safety issues for free-threaded (GIL=0) Python - #108
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
FindIter(pcre2.c)Py_GIL_DISABLEDbuilds, but GIL builds release the GIL aroundpcre2_matchfor subjects > 256 KiB — two threads sharing onefinditeriterator could runpcre2_matchconcurrently on the samematch_data→ torn ovector / heap corruption. The rawPyThreadlock could also deadlock a free-threaded stop-the-world pause (attached waiter)PyMutexon 3.13+ (parks GC-safely); on older GIL builds the lock is acquired with the GIL releasedcache.c)match_context_cache_acquirereturned 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 beyondendpos)Pattern_substitutepcre2_substituteexecutes JIT code but skipped thejit_guardserial lock that every other JIT site takes (PYPCRE_FORCE_JIT_LOCKplatforms)module_execerror pathimportlib.reload) rancache_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 inraise_pcre_error, UAF on freed lockscache_initializeis idempotent; context-cache env toggle applied only on first initmemory.ccurrent_alloc/current_freepair (jemallocmallocpaired withPyMem_Free). (2) Teardowndlclosed jemalloc/tcmalloc and resetcurrent_freewhile allocations (Match ovectors) were still live → free-with-wrong-allocator / call into unmapped code. (3)pcre_freelazily re-initialized the allocator, possibly selecting a different one than produced the pointerpcre_freepattern_cache.c)PyThreadlock held acrossPyDict_SetItem/eviction DECREFs → free-threaded stop-the-world deadlock (attached waiter) and self-deadlock when a GC finalizer re-enterspcre.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 entryPyDict_GetItemReflookups on 3.13+; publish mode after containers exist; map-first eviction;clearempties containers in placeMatch_expandPyByteArray_AS_STRINGwith no pin — concurrentba.clear()/resize frees the buffer mid-memchr→ UAF readMatch/FindIterhold arbitrary user objects (ownerarg) andPatterncan hold str/bytes subclasses, but none hadtp_traverse→ reference cycles leaked permanentlyPy_TPFLAGS_HAVE_GC+ traverse/clear for all three typeserror.craise_pcre_errorINCREF'd a borrowed dict entry (free-threaded race window) and could INCREFPcreError == NULLPyDict_GetItemRefon 3.13+, NULL checkatomic_compat.hATOMIC_VAR_INITwas removed in C23 — gcc 15+/clang 19+ fail to buildPython layer
parallel_mapnested-call deadlock: a task running on apcre-workerthread that itself callsparallel_mapsubmitted 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.pyepoch re-check: the post-compile check compared_THREAD_LOCAL.epoch— a value only the current thread writes — so it could never detect a concurrentclear_cache(); a stale compiled pattern survived a process-wide clear. Now re-reads the global epoch.threads.py: the macOSsysctlsubprocess probe ran while holding the process-wide pool lock, stalling every thread touching the pool. Now probed outside the lock with atomic publication.Testing
sys._is_gil_enabled() == False) and 3.14.7 GIL builds, Linux x86-64, linked against system PCRE2 10.46.finditeriterator over a >800 KB subject; match/findall/sub/split churn on one pattern (context/match-data cache reuse); concurrentcompile+clear_cache; 1000owner-reference cycles collected by GC;Match.expandon a bytearray mutated concurrently by another thread. No crashes, no errors, no deadlocks.🤖 Generated with Claude Code