Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Include/cpython/pystate.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions Lib/test/test_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 33 additions & 2 deletions Objects/object.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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)
{
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
Expand Down
1 change: 1 addition & 0 deletions Python/pystate.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading