diff --git a/pcre/cache.py b/pcre/cache.py index 0139e02..cd5cbb1 100644 --- a/pcre/cache.py +++ b/pcre/cache.py @@ -170,7 +170,12 @@ def _cached_compile_thread_local( except KeyError: compiled = wrapper(_pcre2.compile(pattern, flags=flags, jit=jit)) active_limit = _bounded_cache_limit(_THREAD_LOCAL.cache_limit) - if _THREAD_LOCAL.epoch != current_epoch or active_limit == 0: + # Re-read the *global* epoch: another thread may have run clear_cache() + # while we compiled, and caching the result stamped with the old epoch + # would let a stale pattern survive a process-wide cache clear. + # (_THREAD_LOCAL.epoch is only ever written by this thread, so + # comparing against it could never detect the race.) + if get_cache_epoch() != current_epoch or active_limit == 0: return compiled if len(cache) >= active_limit: cache.pop(next(iter(cache))) diff --git a/pcre/pcre.py b/pcre/pcre.py index 9e23324..8c1d9a1 100644 --- a/pcre/pcre.py +++ b/pcre/pcre.py @@ -1725,6 +1725,14 @@ def _should_use_auto_threads(subjects: list[Any]) -> bool: return False +# Marks threads that are currently executing a parallel_map chunk on the +# shared pool. A nested parallel_map on such a thread must not submit to the +# same pool and block on the results: if every worker does that, all of them +# wait for chunks that can only run on those same workers — a permanent +# deadlock. Nested calls run inline instead. +_PARALLEL_TLS = local() + + def parallel_map( pattern: Any, subjects: Iterable[Any], @@ -1767,6 +1775,12 @@ def parallel_map( "threading defaults." ) + if getattr(_PARALLEL_TLS, "in_worker", False): + return [ + bound_method(subject, pos=pos, endpos=endpos, options=options) + for subject in materials + ] + # A one-item map cannot benefit from worker fan-out. Keep the documented # list-shaped result but avoid executor creation, queueing, and a Future; # this is also the safest path for explicit ``Flag.THREADS`` on tiny jobs. @@ -1846,17 +1860,21 @@ def parallel_map( chunk_size = (len(materials) + task_count - 1) // task_count def _run_chunk(start: int, stop: int) -> list[Any]: - if fast_method is not None: - if fast_method_takes_owner: - return [ - fast_method(materials[index], pattern_obj) - for index in range(start, stop) - ] - return [fast_method(materials[index]) for index in range(start, stop)] - return [ - bound_method(materials[index], pos=pos, endpos=endpos, options=options) - for index in range(start, stop) - ] + _PARALLEL_TLS.in_worker = True + try: + if fast_method is not None: + if fast_method_takes_owner: + return [ + fast_method(materials[index], pattern_obj) + for index in range(start, stop) + ] + return [fast_method(materials[index]) for index in range(start, stop)] + return [ + bound_method(materials[index], pos=pos, endpos=endpos, options=options) + for index in range(start, stop) + ] + finally: + _PARALLEL_TLS.in_worker = False def _make_task(start: int, stop: int) -> Any: return lambda: _run_chunk(start, stop) diff --git a/pcre/threads.py b/pcre/threads.py index 1f7a54a..8f55362 100644 --- a/pcre/threads.py +++ b/pcre/threads.py @@ -39,22 +39,26 @@ def _performance_cpu_total() -> int: """Return macOS performance-tier logical CPUs when the kernel exposes it.""" global _PERFORMANCE_CPU_TOTAL - with _THREAD_POOL_LOCK: - if _PERFORMANCE_CPU_TOTAL is not None: - return _PERFORMANCE_CPU_TOTAL - if sys.platform != "darwin": - _PERFORMANCE_CPU_TOTAL = 0 - return 0 + snapshot = _PERFORMANCE_CPU_TOTAL + if snapshot is not None: + return snapshot + # Probe outside _THREAD_POOL_LOCK: spawning a subprocess under the + # process-wide pool lock stalls every other thread touching the pool. + # A duplicate concurrent probe is harmless; publication is atomic. + if sys.platform != "darwin": + result = 0 + else: try: value = subprocess.check_output( ["sysctl", "-n", "hw.perflevel0.logicalcpu"], text=True, stderr=subprocess.DEVNULL, ) - _PERFORMANCE_CPU_TOTAL = max(0, int(value.strip())) + result = max(0, int(value.strip())) except (OSError, ValueError, subprocess.CalledProcessError): - _PERFORMANCE_CPU_TOTAL = 0 - return _PERFORMANCE_CPU_TOTAL + result = 0 + _PERFORMANCE_CPU_TOTAL = result + return result _THREADS_DEFAULT: bool = threading_supported() and not ( diff --git a/pcre_ext/atomic_compat.h b/pcre_ext/atomic_compat.h index 5f1955f..74cc731 100644 --- a/pcre_ext/atomic_compat.h +++ b/pcre_ext/atomic_compat.h @@ -356,6 +356,13 @@ static __forceinline void atomic_flag_clear_explicit(atomic_flag *obj, memory_or # define ATOMIC_COMPAT_HAVE_ATOMICS 1 # define ATOMIC_VAR(type) _Atomic(type) +/* C17 deprecated ATOMIC_VAR_INIT and C23 removed it (gcc 15+/clang 19+ no + * longer define it). Plain initialization of an _Atomic object is valid in + * every supported standard revision. */ +# ifndef ATOMIC_VAR_INIT +# define ATOMIC_VAR_INIT(value) (value) +# endif + #endif /* _MSC_VER */ #ifdef __cplusplus diff --git a/pcre_ext/cache.c b/pcre_ext/cache.c index 92bcca4..384ccee 100644 --- a/pcre_ext/cache.c +++ b/pcre_ext/cache.c @@ -522,9 +522,17 @@ global_jit_stack_cache_release(pcre2_jit_stack *jit_stack) pcre2_jit_stack_free(jit_stack); } +/* Set once the cache subsystem has fully initialized. A repeated + * module_exec (importlib.reload) must not reset strategies, toggles, or + * cached objects that other threads may be using concurrently. */ +static int cache_initialized = 0; + int cache_initialize(int global_mode) { + if (cache_initialized) { + return 0; + } if (global_cache_lock == NULL) { global_cache_lock = PyThread_allocate_lock(); if (global_cache_lock == NULL) { @@ -562,12 +570,14 @@ cache_initialize(int global_mode) atomic_store_explicit(&global_jit_capacity, 1, memory_order_release); atomic_store_explicit(&global_jit_start_size, 32 * 1024, memory_order_release); atomic_store_explicit(&global_jit_max_size, 1024 * 1024, memory_order_release); + cache_initialized = 1; return 0; } void cache_teardown(void) { + cache_initialized = 0; thread_cache_teardown(); global_cache_teardown(); if (global_cache_lock != NULL) { @@ -605,6 +615,13 @@ match_data_cache_release(pcre2_match_data *match_data) } } +/* + * Match contexts are handed out with exclusive ownership: acquire detaches the + * context from the thread-local slot and release re-stores (or frees) it. + * Returning a pointer that stays resident in the slot would let thread-exit + * cleanup, reentrant acquires (GC finalizers), or a context-cache toggle free + * or mutate a context that is still in use. + */ pcre2_match_context * match_context_cache_acquire(int use_offset_limit) { @@ -626,15 +643,18 @@ match_context_cache_acquire(int use_offset_limit) &state->offset_match_context : &state->match_context; - if (*slot == NULL) { - *slot = pcre2_match_context_create(NULL); - if (*slot == NULL) { - PyErr_NoMemory(); - return NULL; - } + if (*slot != NULL) { + pcre2_match_context *context = *slot; + *slot = NULL; + return context; } - return *slot; + pcre2_match_context *context = pcre2_match_context_create(NULL); + if (context == NULL) { + PyErr_NoMemory(); + return NULL; + } + return context; } void @@ -656,6 +676,28 @@ match_context_cache_release(pcre2_match_context *context, int had_offset_limit) if (!atomic_load_explicit(&context_cache_enabled, memory_order_acquire)) { pcre2_match_context_free(context); + return; + } + + ThreadCacheState *state = thread_cache_state_get(); + if (state == NULL) { + pcre2_match_context_free(context); + return; + } + + pcre2_match_context **preferred = had_offset_limit ? + &state->offset_match_context : + &state->match_context; + pcre2_match_context **fallback = had_offset_limit ? + &state->match_context : + &state->offset_match_context; + + if (*preferred == NULL) { + *preferred = context; + } else if (*fallback == NULL) { + *fallback = context; + } else { + pcre2_match_context_free(context); } } diff --git a/pcre_ext/error.c b/pcre_ext/error.c index 4463493..4834052 100644 --- a/pcre_ext/error.c +++ b/pcre_ext/error.c @@ -1249,6 +1249,11 @@ static PyObject * get_error_type_for_code(int error_code) { if (PcreErrorByCode == NULL) { + if (PcreError == NULL) { + PyErr_SetString(PyExc_RuntimeError, + "pcre error types are not initialized"); + return NULL; + } Py_INCREF(PcreError); return PcreError; } @@ -1258,6 +1263,21 @@ get_error_type_for_code(int error_code) return NULL; } +#if PY_VERSION_HEX >= 0x030D0000 + /* Returns an owned reference under the dict's internal lock, so the entry + * cannot be deallocated between lookup and INCREF on free-threaded builds. */ + PyObject *exc_type = NULL; + int lookup_rc = PyDict_GetItemRef(PcreErrorByCode, code_obj, &exc_type); + Py_DECREF(code_obj); + if (lookup_rc < 0) { + return NULL; + } + if (exc_type == NULL) { + Py_INCREF(PcreError); + return PcreError; + } + return exc_type; +#else PyObject *exc_type = PyDict_GetItemWithError(PcreErrorByCode, code_obj); Py_DECREF(code_obj); if (exc_type == NULL) { @@ -1270,6 +1290,7 @@ get_error_type_for_code(int error_code) Py_INCREF(exc_type); return exc_type; +#endif } void diff --git a/pcre_ext/memory.c b/pcre_ext/memory.c index 50315ba..ba5890b 100644 --- a/pcre_ext/memory.c +++ b/pcre_ext/memory.c @@ -100,13 +100,12 @@ pcre_memory_initialize(void) return 0; } + /* Spin until we own the flag. Waiters must never clear the flag: only + * the owner releases it, otherwise two threads can run the initializer + * concurrently and tear the current_alloc/current_free pair. */ while (atomic_flag_test_and_set_explicit(&allocator_init_lock, memory_order_acq_rel)) { - if (atomic_load_explicit(&allocator_initialized, memory_order_acquire)) { - atomic_flag_clear_explicit(&allocator_init_lock, memory_order_release); - return 0; - } + /* busy-wait */ } - if (atomic_load_explicit(&allocator_initialized, memory_order_acquire)) { atomic_flag_clear_explicit(&allocator_init_lock, memory_order_release); return 0; @@ -185,18 +184,12 @@ pcre_memory_initialize(void) void pcre_memory_teardown(void) { -#if !defined(_WIN32) - void *handle_to_close = current_handle; - current_handle = NULL; - if (handle_to_close != NULL) { - dlclose(handle_to_close); - } -#endif - current_alloc = (alloc_fn)PyMem_Malloc; - current_free = (free_fn)PyMem_Free; - current_name = "pymem"; - atomic_store_explicit(&allocator_initialized, 0, memory_order_release); - atomic_flag_clear_explicit(&allocator_init_lock, memory_order_release); + /* Allocator selection is irreversible for the lifetime of the process. + * Blocks obtained from pcre_malloc() (Match ovectors, error-name buffers) + * can outlive any teardown point, so resetting current_free or dlclosing + * the backing library would pair a live allocation with the wrong + * deallocator (heap corruption) or call into unmapped code. The dlopen + * handle is intentionally retained. */ } void * @@ -216,9 +209,10 @@ pcre_free(void *ptr) if (ptr == NULL) { return; } - if (!atomic_load_explicit(&allocator_initialized, memory_order_acquire)) { - (void)pcre_memory_initialize(); - } + /* Any pointer passed here came from pcre_malloc(), which initialized the + * allocator; the statically-initialized PyMem pair covers the impossible + * uninitialized case. Re-running initialization here could select a + * different allocator than the one that produced `ptr`. */ current_free(ptr); } diff --git a/pcre_ext/pattern_cache.c b/pcre_ext/pattern_cache.c index 8b48cb0..c3eeb51 100644 --- a/pcre_ext/pattern_cache.c +++ b/pcre_ext/pattern_cache.c @@ -16,8 +16,12 @@ typedef struct { static Py_tss_t pattern_cache_tss = Py_tss_NEEDS_INIT; static ATOMIC_VAR(int) pattern_cache_tss_ready = ATOMIC_VAR_INIT(0); +/* In global mode, map/order are created during module_exec before the mode + * flag is published and stay alive for the module's lifetime; mutations are + * serialized with a critical section on the map object. A raw PyThread lock + * here previously deadlocked free-threaded stop-the-world pauses (attached + * waiter) and self-deadlocked when a GC finalizer re-entered pcre.compile. */ static PatternCacheState global_pattern_cache = {NULL, NULL, NULL, MODULE_COMPILE_CACHE_LIMIT}; -static PyThread_type_lock global_pattern_cache_lock = NULL; static ATOMIC_VAR(int) pattern_cache_global_mode = ATOMIC_VAR_INIT(0); static PyObject *pattern_cache_cleanup_key = NULL; @@ -179,72 +183,39 @@ pattern_cache_capsule_destructor(PyObject *capsule) pattern_cache_thread_state_free(state); } -static int -pattern_cache_acquire_state(PatternCacheState **state_out, int *lock_held) +static PatternCacheState * +pattern_cache_thread_state_acquire(void) { - if (state_out == NULL || lock_held == NULL) { - PyErr_SetString(PyExc_RuntimeError, "invalid pattern cache request"); - return -1; - } - - if (pattern_cache_is_global()) { - if (global_pattern_cache_lock != NULL) { - PyThread_acquire_lock(global_pattern_cache_lock, 1); - *lock_held = 1; - } else { - *lock_held = 0; - } - if (pattern_cache_state_ensure(&global_pattern_cache) < 0) { - if (*lock_held) { - PyThread_release_lock(global_pattern_cache_lock); - } - return -1; - } - *state_out = &global_pattern_cache; - return 0; - } - PatternCacheState *state = thread_pattern_cache_state_get_or_create(); if (state == NULL) { - return -1; + return NULL; } if (pattern_cache_state_ensure(state) < 0) { - return -1; - } - *state_out = state; - *lock_held = 0; - return 0; -} - -static inline void -pattern_cache_release_state(int lock_held) -{ - if (lock_held && global_pattern_cache_lock != NULL) { - PyThread_release_lock(global_pattern_cache_lock); + return NULL; } + return state; } int pattern_cache_initialize(int global_mode) { - atomic_store_explicit(&pattern_cache_global_mode, global_mode ? 1 : 0, memory_order_release); if (global_mode) { - if (global_pattern_cache_lock == NULL) { - global_pattern_cache_lock = PyThread_allocate_lock(); - if (global_pattern_cache_lock == NULL) { - PyErr_NoMemory(); - return -1; - } - } global_pattern_cache.limit = MODULE_COMPILE_CACHE_LIMIT; if (pattern_cache_state_ensure(&global_pattern_cache) < 0) { return -1; } + /* Publish the mode only after the shared containers fully exist so a + * concurrent lookup cannot observe global mode with a NULL map. */ + atomic_store_explicit(&pattern_cache_global_mode, 1, memory_order_release); return 0; } - global_pattern_cache.limit = MODULE_COMPILE_CACHE_LIMIT; - pattern_cache_state_clear(&global_pattern_cache); + if (!pattern_cache_is_global()) { + /* Only reset shared state when not already committed to global mode + * (a re-exec must not clear a cache other threads are using). */ + global_pattern_cache.limit = MODULE_COMPILE_CACHE_LIMIT; + } + atomic_store_explicit(&pattern_cache_global_mode, 0, memory_order_release); if (pattern_cache_tss_initialize() < 0) { return -1; } @@ -261,12 +232,11 @@ void pattern_cache_teardown(void) { if (pattern_cache_is_global()) { - pattern_cache_state_clear(&global_pattern_cache); - if (global_pattern_cache_lock != NULL) { - PyThread_free_lock(global_pattern_cache_lock); - global_pattern_cache_lock = NULL; - } + /* Only reached when the very first module initialization fails + * (module_exec guards re-exec error paths), so no other thread can + * hold references into these containers. */ atomic_store_explicit(&pattern_cache_global_mode, 0, memory_order_release); + pattern_cache_state_clear(&global_pattern_cache); Py_CLEAR(pattern_cache_cleanup_key); return; } @@ -305,14 +275,37 @@ pattern_cache_lookup(PyObject *cache_key, PatternObject **out_pattern) } *out_pattern = NULL; - PatternCacheState *state = NULL; - int lock_held = 0; - if (pattern_cache_acquire_state(&state, &lock_held) < 0) { - return -1; + if (pattern_cache_is_global()) { + PyObject *map = global_pattern_cache.map; + if (map == NULL) { + return 0; + } +#if PY_VERSION_HEX >= 0x030D0000 + /* Owned-reference lookup under the dict's internal lock: safe against + * concurrent eviction on free-threaded builds. */ + PyObject *cached = NULL; + if (PyDict_GetItemRef(map, cache_key, &cached) < 0) { + return -1; + } + *out_pattern = (PatternObject *)cached; + return 0; +#else + PyObject *cached = PyDict_GetItemWithError(map, cache_key); + if (cached != NULL) { + Py_INCREF(cached); + *out_pattern = (PatternObject *)cached; + } else if (PyErr_Occurred()) { + return -1; + } + return 0; +#endif } - if (state == NULL || state->map == NULL) { - pattern_cache_release_state(lock_held); + PatternCacheState *state = pattern_cache_thread_state_acquire(); + if (state == NULL) { + return -1; + } + if (state->map == NULL) { return 0; } @@ -321,11 +314,8 @@ pattern_cache_lookup(PyObject *cache_key, PatternObject **out_pattern) Py_INCREF(cached); *out_pattern = (PatternObject *)cached; } else if (PyErr_Occurred()) { - pattern_cache_release_state(lock_held); return -1; } - - pattern_cache_release_state(lock_held); return 0; } @@ -338,36 +328,27 @@ pattern_cache_evict_if_needed(PatternCacheState *state) if (state->limit >= 0 && PyList_GET_SIZE(state->order) > state->limit) { PyObject *old_key = PyList_GET_ITEM(state->order, 0); Py_INCREF(old_key); - if (PySequence_DelItem(state->order, 0) < 0) { + /* Remove from the map first: if the order-list delete fails the entry + * is retried next time, whereas the reverse order could strand an + * unevictable entry in the map and let it grow past the limit. */ + if (PyDict_DelItem(state->map, old_key) < 0) { PyErr_Clear(); - } else if (PyDict_DelItem(state->map, old_key) < 0) { + } + if (PySequence_DelItem(state->order, 0) < 0) { PyErr_Clear(); } Py_DECREF(old_key); } } -int -pattern_cache_store(PyObject *cache_key, PatternObject *pattern) +static int +pattern_cache_store_locked(PatternCacheState *state, PyObject *cache_key, PatternObject *pattern) { - PatternCacheState *state = NULL; - int lock_held = 0; - if (pattern_cache_acquire_state(&state, &lock_held) < 0) { - return -1; - } - - if (state == NULL || state->map == NULL) { - pattern_cache_release_state(lock_held); - return 0; - } - int already_present = PyDict_Contains(state->map, cache_key); if (already_present < 0) { - pattern_cache_release_state(lock_held); return -1; } if (PyDict_SetItem(state->map, cache_key, (PyObject *)pattern) < 0) { - pattern_cache_release_state(lock_held); return -1; } @@ -378,22 +359,57 @@ pattern_cache_store(PyObject *cache_key, PatternObject *pattern) pattern_cache_evict_if_needed(state); } } - - pattern_cache_release_state(lock_held); return 0; } +int +pattern_cache_store(PyObject *cache_key, PatternObject *pattern) +{ + if (pattern_cache_is_global()) { + PyObject *map = global_pattern_cache.map; + if (map == NULL) { + return 0; + } + int rc = 0; + /* Critical sections keep map/order coherent without the deadlocks of + * a raw lock: a blocked waiter parks GC-safely, and reentrant entry + * from a GC finalizer suspends the outer section instead of hanging. */ + Py_BEGIN_CRITICAL_SECTION(map); + rc = pattern_cache_store_locked(&global_pattern_cache, cache_key, pattern); + Py_END_CRITICAL_SECTION(); + return rc; + } + + PatternCacheState *state = pattern_cache_thread_state_acquire(); + if (state == NULL) { + return -1; + } + if (state->map == NULL) { + return 0; + } + return pattern_cache_store_locked(state, cache_key, pattern); +} + void pattern_cache_clear_current(void) { if (pattern_cache_is_global()) { - if (global_pattern_cache_lock != NULL) { - PyThread_acquire_lock(global_pattern_cache_lock, 1); + PyObject *map = global_pattern_cache.map; + if (map == NULL) { + return; } - pattern_cache_state_clear(&global_pattern_cache); - if (global_pattern_cache_lock != NULL) { - PyThread_release_lock(global_pattern_cache_lock); + /* Empty the containers in place; the container objects themselves + * stay alive for the module's lifetime so concurrent lookups and + * stores always see valid dicts/lists. */ + Py_BEGIN_CRITICAL_SECTION(map); + PyDict_Clear(map); + if (global_pattern_cache.order != NULL) { + if (PyList_SetSlice(global_pattern_cache.order, 0, + PyList_GET_SIZE(global_pattern_cache.order), NULL) < 0) { + PyErr_Clear(); + } } + Py_END_CRITICAL_SECTION(); return; } diff --git a/pcre_ext/pcre2.c b/pcre_ext/pcre2.c index 7e050b6..147d8c4 100644 --- a/pcre_ext/pcre2.c +++ b/pcre_ext/pcre2.c @@ -249,6 +249,7 @@ static int match_resolve_span(MatchObject *self, static void Match_dealloc(MatchObject *self) { + PyObject_GC_UnTrack(self); Py_XDECREF(self->pattern); Py_XDECREF(self->public_pattern); Py_XDECREF(self->subject); @@ -258,6 +259,36 @@ Match_dealloc(MatchObject *self) Py_TYPE(self)->tp_free((PyObject *)self); } +/* + * Match/FindIter can hold arbitrary user objects (the `owner` argument stored + * as public_pattern) and str/bytes subclasses, so the GC must be able to see + * through them or reference cycles leak permanently. tp_clear only drops + * public_pattern: breaking any one edge collects the cycle, and keeping + * subject/utf8_owner alive until dealloc means utf8_data can never dangle. + */ +static int +Match_traverse(MatchObject *self, visitproc visit, void *arg) +{ + Py_VISIT(self->pattern); + Py_VISIT(self->public_pattern); + Py_VISIT(self->subject); + Py_VISIT(self->utf8_owner); + Py_VISIT(self->regs_cache); + return 0; +} + +static int +Match_clear(MatchObject *self) +{ + PyObject *old = NULL; + Py_BEGIN_CRITICAL_SECTION(self); + old = self->public_pattern; + self->public_pattern = NULL; + Py_END_CRITICAL_SECTION(); + Py_XDECREF(old); + return 0; +} + static PyObject * Match_repr(MatchObject *self) { @@ -1910,16 +1941,34 @@ Match_expand(MatchObject *self, PyObject *template_obj) } if (self->subject_is_bytes && (PyBytes_Check(template_obj) || PyByteArray_Check(template_obj))) { - const char *template_data = PyBytes_Check(template_obj) - ? PyBytes_AS_STRING(template_obj) - : (const char *)PyByteArray_AS_STRING(template_obj); - Py_ssize_t template_length = PyBytes_Check(template_obj) - ? PyBytes_GET_SIZE(template_obj) - : PyByteArray_GET_SIZE(template_obj); + PyObject *template_snapshot = NULL; + if (PyByteArray_Check(template_obj)) { + /* A bytearray can be resized or freed by another thread while we + * scan its raw buffer, so snapshot it to immutable bytes under + * the object's critical section before touching the data. */ + Py_BEGIN_CRITICAL_SECTION(template_obj); + template_snapshot = PyBytes_FromStringAndSize( + PyByteArray_AS_STRING(template_obj), + PyByteArray_GET_SIZE(template_obj)); + Py_END_CRITICAL_SECTION(); + if (template_snapshot == NULL) { + return NULL; + } + } + const char *template_data = template_snapshot != NULL + ? PyBytes_AS_STRING(template_snapshot) + : PyBytes_AS_STRING(template_obj); + Py_ssize_t template_length = template_snapshot != NULL + ? PyBytes_GET_SIZE(template_snapshot) + : PyBytes_GET_SIZE(template_obj); const char *slash = memchr(template_data, '\\', (size_t)template_length); if (slash == NULL) { + if (template_snapshot != NULL) { + return template_snapshot; + } return PyBytes_FromObject(template_obj); } + Py_XDECREF(template_snapshot); if (PyBytes_CheckExact(template_obj)) { int handled = 0; PyObject *result = match_expand_simple_numeric( @@ -2066,7 +2115,10 @@ PyTypeObject MatchType = { .tp_basicsize = sizeof(MatchObject), .tp_dealloc = (destructor)Match_dealloc, .tp_repr = (reprfunc)Match_repr, - .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, + .tp_traverse = (traverseproc)Match_traverse, + .tp_clear = (inquiry)Match_clear, + .tp_free = PyObject_GC_Del, .tp_methods = Match_methods, .tp_getset = Match_getset, .tp_as_mapping = &Match_as_mapping, @@ -2100,7 +2152,12 @@ typedef struct { int utf8_is_ascii; PyObject *public_pattern; int retry_nonempty; -#if defined(Py_GIL_DISABLED) + /* Serializes iternext on every build: even with the GIL, the match call + * can release it (PCRE2_CALL_MAYBE_RELEASE_GIL), letting a second thread + * run pcre2_match concurrently on the same match_data. */ +#if PY_VERSION_HEX >= 0x030D0000 + PyMutex lock; +#else PyThread_type_lock lock; #endif } FindIterObject; @@ -2320,6 +2377,7 @@ finditer_index_to_byte(FindIterObject *self, Py_ssize_t target_index) static void FindIter_dealloc(FindIterObject *self) { + PyObject_GC_UnTrack(self); if (self->match_data != NULL) { match_data_cache_release(self->match_data); self->match_data = NULL; @@ -2332,7 +2390,7 @@ FindIter_dealloc(FindIterObject *self) jit_stack_cache_release(self->jit_stack); self->jit_stack = NULL; } -#if defined(Py_GIL_DISABLED) +#if PY_VERSION_HEX < 0x030D0000 if (self->lock != NULL) { PyThread_free_lock(self->lock); self->lock = NULL; @@ -2552,22 +2610,53 @@ FindIter_iternext_unlocked(FindIterObject *self) static PyObject * FindIter_iternext(FindIterObject *self) { -#if defined(Py_GIL_DISABLED) - PyThread_acquire_lock(self->lock, WAIT_LOCK); +#if PY_VERSION_HEX >= 0x030D0000 + /* PyMutex parks GC-safely: a blocked waiter neither holds the GIL nor + * stalls a free-threaded stop-the-world pause. */ + PyMutex_Lock(&self->lock); PyObject *result = FindIter_iternext_unlocked(self); - PyThread_release_lock(self->lock); + PyMutex_Unlock(&self->lock); return result; #else - return FindIter_iternext_unlocked(self); + if (!PyThread_acquire_lock(self->lock, NOWAIT_LOCK)) { + /* Blocking while holding the GIL would deadlock against the holder, + * which needs the GIL to finish iternext and release the lock. */ + Py_BEGIN_ALLOW_THREADS + PyThread_acquire_lock(self->lock, WAIT_LOCK); + Py_END_ALLOW_THREADS + } + PyObject *result = FindIter_iternext_unlocked(self); + PyThread_release_lock(self->lock); + return result; #endif } +static int +FindIter_traverse(FindIterObject *self, visitproc visit, void *arg) +{ + Py_VISIT(self->pattern); + Py_VISIT(self->subject); + Py_VISIT(self->utf8_owner); + Py_VISIT(self->public_pattern); + return 0; +} + +static int +FindIter_clear(FindIterObject *self) +{ + Py_CLEAR(self->public_pattern); + return 0; +} + static PyTypeObject FindIterType = { PyVarObject_HEAD_INIT(NULL, 0) .tp_name = "pcre._FindIter", .tp_basicsize = sizeof(FindIterObject), .tp_dealloc = (destructor)FindIter_dealloc, - .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, + .tp_traverse = (traverseproc)FindIter_traverse, + .tp_clear = (inquiry)FindIter_clear, + .tp_free = PyObject_GC_Del, .tp_iter = FindIter_iter, .tp_iternext = (iternextfunc)FindIter_iternext, .tp_doc = "Iterator yielding successive PCRE2 matches.", @@ -2590,7 +2679,7 @@ create_match_object(PatternObject *pattern, * Materialize a standalone match snapshot. The ovector is copied because * PCRE2 reuses match-data buffers from caches across calls and threads. */ - MatchObject *match = PyObject_New(MatchObject, &MatchType); + MatchObject *match = PyObject_GC_New(MatchObject, &MatchType); if (match == NULL) { return NULL; } @@ -2601,7 +2690,7 @@ create_match_object(PatternObject *pattern, #if SIZE_MAX < UINT64_MAX if ((uint64_t)ovec_count > (uint64_t)(SIZE_MAX / sizeof(Py_ssize_t) / 2)) { PyErr_NoMemory(); - PyObject_Del(match); + PyObject_GC_Del(match); return NULL; } #endif @@ -2609,13 +2698,13 @@ create_match_object(PatternObject *pattern, match->ovector = pcre_malloc(alloc_pairs * sizeof(Py_ssize_t)); if (match->ovector == NULL) { PyErr_NoMemory(); - PyObject_Del(match); + PyObject_GC_Del(match); return NULL; } if (ovector == NULL) { PyErr_NoMemory(); pcre_free(match->ovector); - PyObject_Del(match); + PyObject_GC_Del(match); return NULL; } @@ -2645,6 +2734,7 @@ create_match_object(PatternObject *pattern, group values are returned as bytes. */ match->subject_is_bytes = !PyUnicode_Check(subject_obj); + PyObject_GC_Track(match); return match; } @@ -2656,7 +2746,7 @@ Pattern_create_finditer(PatternObject *pattern, uint32_t options, PyObject *public_pattern) { - FindIterObject *iter = PyObject_New(FindIterObject, &FindIterType); + FindIterObject *iter = PyObject_GC_New(FindIterObject, &FindIterType); if (iter == NULL) { return NULL; } @@ -2689,7 +2779,9 @@ Pattern_create_finditer(PatternObject *pattern, iter->utf8_is_ascii = 0; iter->public_pattern = NULL; iter->retry_nonempty = 0; -#if defined(Py_GIL_DISABLED) +#if PY_VERSION_HEX >= 0x030D0000 + memset(&iter->lock, 0, sizeof(iter->lock)); +#else iter->lock = PyThread_allocate_lock(); if (iter->lock == NULL) { PyErr_NoMemory(); @@ -2881,6 +2973,7 @@ Pattern_create_finditer(PatternObject *pattern, iter->base_options |= PCRE2_NO_UTF_CHECK; } + PyObject_GC_Track(iter); return (PyObject *)iter; error: @@ -2901,13 +2994,13 @@ Pattern_create_finditer(PatternObject *pattern, Py_XDECREF(iter->utf8_owner); Py_XDECREF(iter->subject); Py_XDECREF(iter->pattern); -#if defined(Py_GIL_DISABLED) +#if PY_VERSION_HEX < 0x030D0000 if (iter->lock != NULL) { PyThread_free_lock(iter->lock); iter->lock = NULL; } #endif - PyObject_Del(iter); + PyObject_GC_Del(iter); return NULL; } @@ -2921,6 +3014,7 @@ typedef enum { static void Pattern_dealloc(PatternObject *self) { + PyObject_GC_UnTrack(self); #if !defined(PCRE_EXT_HAVE_ATOMICS) if (self->jit_lock != NULL) { PyThread_free_lock(self->jit_lock); @@ -4231,17 +4325,28 @@ Pattern_substitute(PatternObject *self, for (int attempts = 0; attempts < 5; ++attempts) { limit_state.stopped = 0; - int rc = pcre2_substitute(self->code, - (PCRE2_SPTR)subject_data, - (PCRE2_SIZE)subject_length, - 0, - sub_options, - match_data, - match_context, - (PCRE2_SPTR)repl_data, - (PCRE2_SIZE)repl_length, - out, - &outlen); + int rc; + /* pcre2_substitute executes JIT-compiled code when the pattern is + * JIT-enabled, so it needs the same serialization guard as every + * other JIT execution site (PYPCRE_FORCE_JIT_LOCK platforms). */ + int guard_jit = (jit_stack != NULL); + if (guard_jit) { + jit_guard_acquire(); + } + rc = pcre2_substitute(self->code, + (PCRE2_SPTR)subject_data, + (PCRE2_SIZE)subject_length, + 0, + sub_options, + match_data, + match_context, + (PCRE2_SPTR)repl_data, + (PCRE2_SIZE)repl_length, + out, + &outlen); + if (guard_jit) { + jit_guard_release(); + } if (rc == PCRE2_ERROR_NOMEMORY) { PCRE2_SIZE required = outlen; if (count > 1) { @@ -5273,13 +5378,28 @@ static PyGetSetDef Pattern_getset[] = { {NULL, NULL, NULL, NULL, NULL}, }; +/* Pattern.pattern may be a str/bytes subclass instance whose attributes can + * refer back to the Pattern, so the GC needs visibility. No tp_clear: the + * cycle is broken through the subclass instance's clearable __dict__, and the + * Pattern's own references stay valid until dealloc. */ +static int +Pattern_traverse(PatternObject *self, visitproc visit, void *arg) +{ + Py_VISIT(self->pattern); + Py_VISIT(self->pattern_bytes); + Py_VISIT(self->groupindex); + return 0; +} + PyTypeObject PatternType = { PyVarObject_HEAD_INIT(NULL, 0) .tp_name = "pcre.Pattern", .tp_basicsize = sizeof(PatternObject), .tp_dealloc = (destructor)Pattern_dealloc, .tp_repr = (reprfunc)Pattern_repr, - .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, + .tp_traverse = (traverseproc)Pattern_traverse, + .tp_free = PyObject_GC_Del, .tp_methods = Pattern_methods, .tp_getset = Pattern_getset, .tp_doc = "Compiled PCRE2 pattern.", @@ -5379,7 +5499,7 @@ Pattern_create(PyObject *pattern_obj, uint32_t options, int jit, int jit_explici return NULL; } - PatternObject *pattern = PyObject_New(PatternObject, &PatternType); + PatternObject *pattern = PyObject_GC_New(PatternObject, &PatternType); if (pattern == NULL) { pcre2_code_free(code); Py_DECREF(pattern_bytes); @@ -5399,7 +5519,7 @@ Pattern_create(PyObject *pattern_obj, uint32_t options, int jit, int jit_explici pattern->jit_lock = PyThread_allocate_lock(); if (pattern->jit_lock == NULL) { PyErr_NoMemory(); - PyObject_Del(pattern); + PyObject_GC_Del(pattern); pcre2_code_free(code); Py_DECREF(pattern_bytes); return NULL; @@ -5479,6 +5599,7 @@ Pattern_create(PyObject *pattern_obj, uint32_t options, int jit, int jit_explici } } + PyObject_GC_Track(pattern); return pattern; } @@ -5968,6 +6089,12 @@ jit_anchor_fixup_needed(void) return needed; } +/* Set after the first fully successful module_exec. The teardown helpers + * destroy process-global state (locks, TSS keys, exception types, caches) + * that live threads from a previous successful import may be using, so a + * failed re-exec (importlib.reload) must never run them. */ +static ATOMIC_VAR(int) module_fully_initialized = ATOMIC_VAR_INIT(0); + static int module_exec(PyObject *module) { @@ -5992,6 +6119,7 @@ module_exec(PyObject *module) const char *pattern_cache_env = NULL; int pattern_cache_global = 0; int force_jit_lock = 0; + int first_init = !atomic_load_explicit(&module_fully_initialized, memory_order_acquire); force_lock_env = Py_GETENV("PYPCRE_FORCE_JIT_LOCK"); if (force_lock_env == NULL) { @@ -6002,11 +6130,15 @@ module_exec(PyObject *module) goto error_jit_support; } - context_cache_env = Py_GETENV("PYPCRE_DISABLE_CONTEXT_CACHE"); - if (context_cache_env == NULL) { - context_cache_env = Py_GETENV("PCRE2_DISABLE_CONTEXT_CACHE"); + if (first_init) { + /* Flipping the context-cache toggle on a re-exec would change the + * ownership rules for contexts that other threads hold in flight. */ + context_cache_env = Py_GETENV("PYPCRE_DISABLE_CONTEXT_CACHE"); + if (context_cache_env == NULL) { + context_cache_env = Py_GETENV("PCRE2_DISABLE_CONTEXT_CACHE"); + } + cache_set_context_cache_enabled(env_flag_is_true(context_cache_env) ? 0 : 1); } - cache_set_context_cache_enabled(env_flag_is_true(context_cache_env) ? 0 : 1); pattern_cache_env = Py_GETENV("PYPCRE_CACHE_PATTERN_GLOBAL"); if (pattern_cache_env == NULL) { @@ -6072,18 +6204,33 @@ module_exec(PyObject *module) goto error_cache; } + atomic_store_explicit(&module_fully_initialized, 1, memory_order_release); return 0; + /* Teardowns free process-global locks, TSS keys, and exception types. + * They are only safe while nothing else can be using that state, i.e. + * when the very first initialization fails. A failed re-exec leaks the + * partially-updated state instead of corrupting live users. */ error_cache: - cache_teardown(); + if (first_init) { + cache_teardown(); + } error_errors: - pcre_error_teardown(); + if (first_init) { + pcre_error_teardown(); + } error_memory: - pcre_memory_teardown(); + if (first_init) { + pcre_memory_teardown(); + } error_pattern_cache: - pattern_cache_teardown(); + if (first_init) { + pattern_cache_teardown(); + } error_jit_support: - jit_support_teardown(); + if (first_init) { + jit_support_teardown(); + } return -1; }