From 67612e6bc52eff55e847dd84de04672ec30b626f Mon Sep 17 00:00:00 2001 From: 1ndahous3 <9205230+1ndahous3@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:07:28 +0300 Subject: [PATCH] Guard untracked frames against recursive deallocation Assisted-by: Codex:gpt-6 --- crates/vm/src/object/core.rs | 15 +++---- .../stdlib_threading_current_frames.py | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index eb7edc93cca..1eed9b712a0 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -173,17 +173,14 @@ pub(super) unsafe fn default_dealloc(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::) } { + if guarded && !unsafe { trashcan::begin(obj, default_dealloc::) } { return; // deferred to queue } @@ -262,7 +259,7 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { } // Trashcan: decrement depth and process deferred objects at outermost level - if tracked { + if guarded { unsafe { trashcan::end() }; } } diff --git a/extra_tests/snippets/stdlib_threading_current_frames.py b/extra_tests/snippets/stdlib_threading_current_frames.py index e93a222148e..332c29eb5e7 100644 --- a/extra_tests/snippets/stdlib_threading_current_frames.py +++ b/extra_tests/snippets/stdlib_threading_current_frames.py @@ -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() + print("ok")