Context
After my first merged CPython change (a one-line stdlib email fix), I wanted to understand how CPython actually works under the hood — refcounting, the C stack, object teardown. That led me to issue #149146 and PR #151595.
This post is about that open PR: a segfault during tuple cleanup, the trashcan mechanism in _Py_Dealloc, and what core developer review looks like at the C layer.
Open PR: python/cpython#151595
Issue: python/cpython#149146
Status: Open — changes requested (Jun 19, 2026)
The bug — Python segfaults while cleaning up
Under tight memory limits, this innocent-looking loop can end in a segmentation fault, not a clean MemoryError:
b = 9
while True:
b = (b, None)
What should happen: memory runs out → MemoryError → Python unwinds and frees objects.
What actually happens on main today: MemoryError is raised correctly, but during cleanup Python recursively calls tuple_dealloc thousands of times. The C stack blows up → SIGSEGV (exit 139).
You need a memory-constrained environment to reproduce it reliably (e.g. Docker with ulimit -v 524288). On a normal laptop with plenty of RAM, you may only see MemoryError — which is why this bug hid for a while.
Why this is deep Python — the trashcan
This is not a Python-level bug you fix with a one-liner in Lib/. It lives in CPython's object deallocation path:
tuple_dealloc → Py_XDECREF → _Py_Dealloc → tuple_dealloc → …
CPython has a trashcan mechanism in _Py_Dealloc (Objects/object.c) that breaks up deep deallocation chains so they don't recurse on the C stack forever. The trashcan decides when to "deposit" objects for later cleanup based on _Py_RecursionLimit_GetMargin() — comparing the machine stack pointer to a soft limit stored in the thread state.
The problem: under RLIMIT_AS (virtual memory cap), the kernel can SIGSEGV while trying to grow the stack before the stack pointer ever gets close to Python's soft limit. The trashcan's signal never fires. Sibling issue #150722 has an LLDB trace showing the stack pointer still ~7 MB above the hard limit when the kernel kills the process.
Python's stack-based trigger is blind to this failure mode.
Historical context
Until PR #132280 consolidated the trashcan into _Py_Dealloc, CPython used a counter (_PyTrash_UNWIND_LEVEL = 50) as a backstop. That counter-based protection disappeared when the trigger switched to stack-pointer margins only — which is why this regression shows up under RLIMIT_AS today.
What my PR proposes
PR #151595 adds a per-thread c_dealloc_depth counter as a fallback trigger inside _Py_Dealloc:
- Stack-pointer margin stays the primary signal
- Counter kicks in at depth 50 when the stack signal cannot fire
- Counter is only touched for GC-tracked types — non-GC types pay zero overhead
Files touched:
| File | What changes |
|---|---|
Include/cpython/pystate.h |
New c_dealloc_depth field on thread state |
Python/pystate.c |
Initialize counter to 0 |
Objects/object.c |
Deposit/drain logic + counter bookkeeping during trashcan drain |
Lib/test/test_gc.py |
Regression test — 100k nested (b, None) tuples, del b must not crash |
The subtle part — deposit vs drain
Fixing the deposit side alone was not enough. I proved the counter was firing (DEPOSIT margin=241 depth=50), but Docker still segfaulted.
The drain path had the same problem: margin >= 4 is always true in the RLIMIT_AS scenario, so every _Py_Dealloc exit could re-enter _PyTrash_thread_destroy_chain from inside a deallocator — rebuilding the same unbounded C recursion the trashcan exists to prevent.
The fix mirrors historical delete_nesting bookkeeping: _PyTrash_thread_destroy_chain bumps c_dealloc_depth for the duration of the drain, and the drain check in _Py_Dealloc becomes c_dealloc_depth == 0 only.
Locally: Docker repro goes from exit 139 (segfault) to exit 1 (MemoryError). test_gc, test_exceptions, and test_capi all pass.
Review reality — not every PR merges
Victor Storchaka (@vstinner) reviewed on Jun 19 and requested changes. His view:
_Py_RecursionLimit_GetMargin()is already complex — adding a second mechanism (c_dealloc_depth) may not be the right direction- Depth 50 may be too low; normal workloads can deallocate much longer chains without problems
- gh-149146 is an extreme corner case (OS cannot enlarge the stack because system memory is exhausted) — maybe Python should do nothing
That's valuable feedback even when it's not what you hoped to hear. Contributing at the C layer is as much about learning whether your mental model of the runtime is correct as it is about landing the merge.
I'm deciding next steps on the PR. I'll post an update here when there's a conclusion.
Stdlib fix vs C internals — two different worlds
| Merged #151120 (email) | Open #151595 (trashcan) | |
|---|---|---|
| Language | Pure Python (Lib/email/) |
C (Objects/object.c, pystate.h) |
| Skills | Read stdlib, write tests | Stack pointers, refcounting, thread state |
| Repro | Copy-paste in any shell | Docker + ulimit, LLDB traces |
| Fix size | One line | Counter + drain bookkeeping across 3 C files |
| Review | Serhiy — fix at the source | Victor — maybe do nothing |
If you want to understand how CPython actually runs, issues like #149146 are where the textbook ends and the source code begins.
If you want to explore this yourself
- Build CPython from source:
./configure --with-pydebug && make - Read
_Py_DeallocinObjects/object.candtuple_deallocinObjects/tupleobject.c - Reproduce under Docker with a virtual memory cap (
ulimit -v) - Run
python -m test test_gc -vafter any changes - Read the Python Developer's Guide internals section
Links
- PR #151595 — trashcan dealloc-depth fallback
- Issue #149146 — segfault cleaning up nested tuples after MemoryError
- Issue #150722 — related stack-signal blindness (comparison path)
- My merged first PR post
Follow along on StackLog for updates as this PR moves forward.