From 55620976d3ef5a67cee1ae5a8e7310f64b80ce1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cbhuvi27=E2=80=9D?= Date: Wed, 17 Jun 2026 15:27:04 +0530 Subject: [PATCH] gh-149146: Add dealloc-depth counter fallback to trashcan trigger Under memory pressure (for example RLIMIT_AS), Python's trashcan trigger in _Py_Dealloc never fires. The trigger relies on _Py_RecursionLimit_GetMargin, which compares the machine stack pointer against c_stack_soft_limit. When RLIMIT_AS prevents the kernel from growing the C stack, the kernel SIGSEGVs while the stack pointer is still megabytes above the soft limit (see sibling issue gh-150722 for an LLDB trace showing SP ~7.2 MB above c_stack_hard_limit at the SIGSEGV), so the trashcan never deposits and the recursive tuple_dealloc chain runs out the stack. Add a per-thread c_dealloc_depth counter as a fallback trigger, mirroring the historical _PyTrash_UNWIND_LEVEL=50 protection that was removed when the trashcan was consolidated into _Py_Dealloc. _Py_RecursionLimit_GetMargin remains the primary signal; the counter only kicks in when the stack-pointer signal cannot fire. _PyTrash_thread_destroy_chain itself bumps c_dealloc_depth for the duration of the drain so any _Py_Dealloc invoked while draining cannot recursively re-enter destroy_chain (which would rebuild the same unbounded recursion the trashcan exists to prevent). This mirrors the historical delete_nesting bookkeeping. The counter is per-tstate (no atomics needed in the free-threaded build) and is only touched for GC types, so non-GC types pay zero overhead. Add a regression test in test_gc that builds a 100,000-deep (b, None) tuple chain and asserts that ``del b`` cleans it up without a C-stack overflow. --- Include/cpython/pystate.h | 8 +++++ Lib/test/test_gc.py | 13 +++++++ ...-06-17-15-55-00.gh-issue-149146.Tr8aLk.rst | 6 ++++ Objects/object.c | 35 +++++++++++++++++-- Python/pystate.c | 1 + 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-17-15-55-00.gh-issue-149146.Tr8aLk.rst diff --git a/Include/cpython/pystate.h b/Include/cpython/pystate.h index a9d97e47e005df..91655ba75c88b8 100644 --- a/Include/cpython/pystate.h +++ b/Include/cpython/pystate.h @@ -177,6 +177,14 @@ struct _ts { */ PyObject *delete_later; + /* gh-149146: per-thread recursion counter for _Py_Dealloc. Acts as a + * fallback trigger for the trashcan in scenarios where the + * stack-pointer-based _Py_RecursionLimit_GetMargin cannot fire (most + * notably when RLIMIT_AS prevents the kernel from growing the C stack, + * so the kernel SIGSEGVs while the stack pointer is still well above + * c_stack_soft_limit). */ + int c_dealloc_depth; + /* Tagged pointer to top-most critical section, or zero if there is no * active critical section. Critical sections are only used in * `--disable-gil` builds (i.e., when Py_GIL_DISABLED is defined to 1). In the diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 3fc084ea6e9c6e..3febd2be521ebd 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -468,6 +468,19 @@ def __del__(self): v = {1: v, 2: Ouch()} gc.disable() + def test_trashcan_nested_tuple_deep(self): + # gh-149146: deallocating a deeply nested ``(b, None)``-style tuple + # chain must not blow the C stack. The trashcan inside + # ``_Py_Dealloc`` has two complementary triggers: the + # stack-pointer-based ``_Py_RecursionLimit_GetMargin`` check and a + # per-thread dealloc-depth counter. The counter ensures that the + # trashcan still fires when the stack-pointer check cannot, e.g. + # when ``RLIMIT_AS`` prevents the kernel from growing the C stack. + b = None + for _ in range(100_000): + b = (b, None) + del b # must not segfault + @threading_helper.requires_working_threading() def test_trashcan_threads(self): # Issue #13992: trashcan mechanism should be thread-safe diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-17-15-55-00.gh-issue-149146.Tr8aLk.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-17-15-55-00.gh-issue-149146.Tr8aLk.rst new file mode 100644 index 00000000000000..c28400dc34acfa --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-17-15-55-00.gh-issue-149146.Tr8aLk.rst @@ -0,0 +1,6 @@ +Fix a crash in :c:func:`!_Py_Dealloc` when deallocating deeply nested +container objects under memory pressure (for example after a +:exc:`MemoryError`). The trashcan now also deposits objects based on a +per-thread dealloc-depth counter, not only on the stack-pointer margin, +so it still defers cleanup when ``RLIMIT_AS`` prevents the kernel from +growing the C stack. Patch by Bhuvi. diff --git a/Objects/object.c b/Objects/object.c index bd23c2e2388194..927f29af0ad916 100644 --- a/Objects/object.c +++ b/Objects/object.c @@ -3201,6 +3201,11 @@ _PyTrash_thread_deposit_object(PyThreadState *tstate, PyObject *op) void _PyTrash_thread_destroy_chain(PyThreadState *tstate) { + /* gh-149146: bump c_dealloc_depth for the duration of the drain so + * that _Py_Dealloc invoked from the deallocators below does not see + * depth == 0 and re-enter destroy_chain recursively. Mirrors the + * historical _PyTrash_end / delete_nesting bookkeeping. */ + tstate->c_dealloc_depth++; while (tstate->delete_later) { PyObject *op = tstate->delete_later; destructor dealloc = Py_TYPE(op)->tp_dealloc; @@ -3226,6 +3231,7 @@ _PyTrash_thread_destroy_chain(PyThreadState *tstate) _PyObject_ASSERT(op, Py_REFCNT(op) == 0); (*dealloc)(op); } + tstate->c_dealloc_depth--; } void _Py_NO_RETURN @@ -3286,6 +3292,19 @@ next" object in the chain to 0. This can easily lead to stack overflows. To avoid that, if the C stack is nearing its limit, instead of calling dealloc on the object, it is added to a queue to be freed later when the stack is shallower */ + +/* gh-149146: Fallback trigger for the trashcan. + * + * The primary trigger above (margin < 2) compares the machine stack pointer + * with c_stack_soft_limit. Under RLIMIT_AS the kernel can refuse to grow + * the C stack and SIGSEGV while the stack pointer is still well above + * c_stack_soft_limit, so the primary trigger never fires. This counter + * deposits into the trashcan once we have recursed through _Py_Dealloc + * enough times to be sure no realistic dealloc chain would overflow the + * stack first. The value matches the historical _PyTrash_UNWIND_LEVEL + * (50) used before the trashcan was consolidated into _Py_Dealloc. */ +#define _Py_DEALLOC_DEPTH_LIMIT 50 + void _Py_Dealloc(PyObject *op) { @@ -3294,10 +3313,14 @@ _Py_Dealloc(PyObject *op) destructor dealloc = type->tp_dealloc; PyThreadState *tstate = _PyThreadState_GET(); intptr_t margin = _Py_RecursionLimit_GetMargin(tstate); - if (margin < 2 && gc_flag) { + if (gc_flag && (margin < 2 + || tstate->c_dealloc_depth >= _Py_DEALLOC_DEPTH_LIMIT)) { _PyTrash_thread_deposit_object(tstate, (PyObject *)op); return; } + if (gc_flag) { + tstate->c_dealloc_depth++; + } #ifdef Py_DEBUG #if !defined(Py_GIL_DISABLED) && !defined(Py_STACKREF_DEBUG) /* This assertion doesn't hold for the free-threading build, as @@ -3340,7 +3363,15 @@ _Py_Dealloc(PyObject *op) Py_XDECREF(old_exc); Py_DECREF(type); #endif - if (tstate->delete_later && margin >= 4 && gc_flag) { + if (gc_flag) { + tstate->c_dealloc_depth--; + } + /* gh-149146: only drain at the very top of the dealloc chain. + * _PyTrash_thread_destroy_chain itself bumps c_dealloc_depth so any + * _Py_Dealloc invoked while draining cannot recursively re-enter the + * drain (which would otherwise rebuild the same unbounded recursion + * the trashcan exists to prevent). */ + if (tstate->delete_later && gc_flag && tstate->c_dealloc_depth == 0) { _PyTrash_thread_destroy_chain(tstate); } } diff --git a/Python/pystate.c b/Python/pystate.c index fed1df0173bacf..e2a221c48fbaff 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -1628,6 +1628,7 @@ init_threadstate(_PyThreadStateImpl *_tstate, _tstate->jit_tracer_state = NULL; #endif tstate->delete_later = NULL; + tstate->c_dealloc_depth = 0; llist_init(&_tstate->mem_free_queue); llist_init(&_tstate->asyncio_tasks_head);