Skip to content
Merged
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
15 changes: 6 additions & 9 deletions crates/vm/src/object/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,14 @@ pub(super) unsafe fn default_dealloc<T: PyPayload>(obj: *mut PyObject) {
return; // resurrected by __del__
}

// Only tracked objects take the trashcan recursion guard and untrack path.
// Untracked objects either own no children (int, float, str, ...) or, like
// non-escaped frames, are released at interpreter depth with at most one
// unguarded link before their tracked children (dicts, functions, code)
// re-enter guarded deallocation, so recursion stays bounded. A frame stored
// in an object graph is forced to escape, becoming tracked and guarded here.
// Read once and reuse for both gates below.
// Untracked payloads can still own recursive chains of references, such as
// detached frames returned by sys._current_frames(). Guard traversable
// payloads regardless of tracking, while keeping scalar deallocation cheap.
let tracked = obj_ref.is_gc_tracked();
let guarded = tracked || T::HAS_TRAVERSE;

// Trashcan: limit recursive deallocation depth to prevent stack overflow
if tracked && !unsafe { trashcan::begin(obj, default_dealloc::<T>) } {
if guarded && !unsafe { trashcan::begin(obj, default_dealloc::<T>) } {
return; // deferred to queue
}

Expand Down Expand Up @@ -262,7 +259,7 @@ pub(super) unsafe fn default_dealloc<T: PyPayload>(obj: *mut PyObject) {
}

// Trashcan: decrement depth and process deferred objects at outermost level
if tracked {
if guarded {
unsafe { trashcan::end() };
}
}
Expand Down
44 changes: 44 additions & 0 deletions extra_tests/snippets/stdlib_threading_current_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,48 @@ def f123():
leave.set()
t.join()


def release_deep_snapshot():
# Detached snapshots can own a deep chain of untracked frames. Releasing
# one on a small native stack must not recurse through the entire chain.
depth = 5000
ready = threading.Event()
release = threading.Event()
disposed = threading.Event()

def recurse(n):
if n:
recurse(n - 1)
else:
ready.set()
release.wait()

def inspect():
frame = sys._current_frames()[worker.ident]
del frame
disposed.set()

old_limit = sys.getrecursionlimit()
old_stack = threading.stack_size()
worker = threading.Thread(target=recurse, args=(depth,), daemon=True)
try:
sys.setrecursionlimit(depth + 100)
worker.start()
assert ready.wait(30), "worker did not reach the bottom of its stack"
threading.stack_size(256 * 1024)
observer = threading.Thread(target=inspect, daemon=True)
observer.start()
observer.join(30)
assert not observer.is_alive(), "snapshot disposal did not finish"
assert disposed.is_set(), "snapshot disposal failed"
finally:
threading.stack_size(old_stack)
release.set()
worker.join(30)
sys.setrecursionlimit(old_limit)
assert not worker.is_alive(), "worker did not finish"


release_deep_snapshot()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
print("ok")
Loading