From a49a7329d2405265d7782caa11e3a0cb608131e5 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sat, 17 Jan 2026 21:56:21 +0900 Subject: [PATCH 1/4] mark poluting tests --- Lib/test/test_weakref.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_weakref.py b/Lib/test/test_weakref.py index ac4e8f82b9c..5415f951acc 100644 --- a/Lib/test/test_weakref.py +++ b/Lib/test/test_weakref.py @@ -1952,6 +1952,7 @@ def test_threaded_weak_valued_pop(self): x = d.pop(10, 10) self.assertIsNot(x, None) # we never put None in there! + @unittest.skip("TODO: RUSTPYTHON; race condition between GC and WeakValueDictionary callback") @threading_helper.requires_working_threading() def test_threaded_weak_valued_consistency(self): # Issue #28427: old keys should not remove new values from From df8b67df26ab611daa4e475f15d319abcf3a4df5 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 21 Feb 2026 00:02:33 +0900 Subject: [PATCH 2/4] GC-infra independent to EBR --- .cspell.dict/cpython.txt | 2 + .cspell.json | 3 + Cargo.toml | 2 +- Lib/test/support/__init__.py | 7 - Lib/test/test_weakref.py | 3 - Lib/test/test_weakset.py | 2 - crates/common/src/refcount.rs | 246 +++++++++++++++++--- crates/vm/src/builtins/type.rs | 4 + crates/vm/src/gc_state.rs | 393 ++++++++++++++++++++++++++++++-- crates/vm/src/object/core.rs | 49 ++-- crates/vm/src/signal.rs | 1 + crates/vm/src/vm/interpreter.rs | 23 +- crates/vm/src/vm/mod.rs | 79 ++++--- crates/vm/src/vm/thread.rs | 1 + 14 files changed, 683 insertions(+), 132 deletions(-) diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index f428c42e5f6..9fe8ea4426f 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -195,6 +195,7 @@ uncollectable Unhandle unparse unparser +untracking VARKEYWORDS varkwarg venvlauncher @@ -209,5 +210,6 @@ webpki winconsoleio withitem withs +worklist xstat XXPRIME diff --git a/.cspell.json b/.cspell.json index 0a93ac35cd5..a6b20e2854f 100644 --- a/.cspell.json +++ b/.cspell.json @@ -66,6 +66,7 @@ "emscripten", "excs", "finalizer", + "finalizers", "GetSet", "groupref", "internable", @@ -122,12 +123,14 @@ "tracebacks", "typealiases", "typevartuples", + "uncollectable", "unhashable", "uninit", "unraisable", "unresizable", "varint", "wasi", + "weaked", "zelf", // unix "posixshmem", diff --git a/Cargo.toml b/Cargo.toml index 6356eef8c0e..0a6497568e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ winresource = "0.1" rustpython-compiler = { workspace = true } rustpython-pylib = { workspace = true, optional = true } rustpython-stdlib = { workspace = true, optional = true, features = ["compiler"] } -rustpython-vm = { workspace = true, features = ["compiler"] } +rustpython-vm = { workspace = true, features = ["compiler", "gc"] } ruff_python_parser = { workspace = true } cfg-if = { workspace = true } diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index cfeeac6deb4..6b3f2c447e8 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -883,13 +883,6 @@ def disable_gc(): @contextlib.contextmanager def gc_threshold(*args): - # TODO: RUSTPYTHON; GC is not supported yet - try: - yield - finally: - pass - return - import gc old_threshold = gc.get_threshold() gc.set_threshold(*args) diff --git a/Lib/test/test_weakref.py b/Lib/test/test_weakref.py index 5415f951acc..8e90ab83cdb 100644 --- a/Lib/test/test_weakref.py +++ b/Lib/test/test_weakref.py @@ -897,7 +897,6 @@ def test_init(self): # No exception should be raised here gc.collect() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: .A'> != None def test_classes(self): # Check that classes are weakrefable. class A(object): @@ -1330,11 +1329,9 @@ def check_len_cycles(self, dict_type, cons): self.assertIn(n1, (0, 1)) self.assertEqual(n2, 0) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_weak_keyed_len_cycles(self): self.check_len_cycles(weakref.WeakKeyDictionary, lambda k: (k, 1)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_weak_valued_len_cycles(self): self.check_len_cycles(weakref.WeakValueDictionary, lambda k: (1, k)) diff --git a/Lib/test/test_weakset.py b/Lib/test/test_weakset.py index af9bbe7cd41..76e8e5c8ab7 100644 --- a/Lib/test/test_weakset.py +++ b/Lib/test/test_weakset.py @@ -403,8 +403,6 @@ def testcontext(): s.clear() self.assertEqual(len(s), 0) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_len_cycles(self): N = 20 items = [RefCycle() for i in range(N)] diff --git a/crates/common/src/refcount.rs b/crates/common/src/refcount.rs index 4a8ed0bf51d..5ba4ec5277c 100644 --- a/crates/common/src/refcount.rs +++ b/crates/common/src/refcount.rs @@ -1,4 +1,17 @@ -use crate::atomic::{Ordering::*, PyAtomic, Radium}; +use crate::atomic::{Ordering, PyAtomic, Radium}; + +// State layout (usize): +// [1 bit: destructed] [1 bit: reserved] [1 bit: leaked] [N bits: weak_count] [M bits: strong_count] +// 64-bit: N=30, M=31. 32-bit: N=14, M=15. +const FLAG_BITS: u32 = 3; +const DESTRUCTED: usize = 1 << (usize::BITS - 1); +const LEAKED: usize = 1 << (usize::BITS - 3); +const TOTAL_COUNT_WIDTH: u32 = usize::BITS - FLAG_BITS; +const WEAK_WIDTH: u32 = TOTAL_COUNT_WIDTH / 2; +const STRONG_WIDTH: u32 = TOTAL_COUNT_WIDTH - WEAK_WIDTH; +const STRONG: usize = (1 << STRONG_WIDTH) - 1; +const COUNT: usize = 1; +const WEAK_COUNT: usize = 1 << STRONG_WIDTH; #[inline(never)] #[cold] @@ -9,15 +22,56 @@ fn refcount_overflow() -> ! { core::panic!("refcount overflow"); } -/// from alloc::sync -/// A soft limit on the amount of references that may be made to an `Arc`. -/// -/// Going above this limit will abort your program (although not -/// necessarily) at _exactly_ `MAX_REFCOUNT + 1` references. -const MAX_REFCOUNT: usize = isize::MAX as usize; +/// State wraps reference count + flags in a single word (platform usize) +#[derive(Clone, Copy)] +struct State { + inner: usize, +} + +impl State { + #[inline] + fn from_raw(inner: usize) -> Self { + Self { inner } + } + + #[inline] + fn as_raw(self) -> usize { + self.inner + } + + #[inline] + fn strong(self) -> u32 { + ((self.inner & STRONG) / COUNT) as u32 + } + + #[inline] + fn destructed(self) -> bool { + (self.inner & DESTRUCTED) != 0 + } + #[inline] + fn leaked(self) -> bool { + (self.inner & LEAKED) != 0 + } + + #[inline] + fn add_strong(self, val: u32) -> Self { + Self::from_raw(self.inner + (val as usize) * COUNT) + } + + #[inline] + fn with_leaked(self, leaked: bool) -> Self { + Self::from_raw((self.inner & !LEAKED) | if leaked { LEAKED } else { 0 }) + } +} + +/// Reference count using state layout with LEAKED support. +/// +/// State layout (usize): +/// 64-bit: [1 bit: destructed] [1 bit: reserved] [1 bit: leaked] [30 bits: weak_count] [31 bits: strong_count] +/// 32-bit: [1 bit: destructed] [1 bit: reserved] [1 bit: leaked] [14 bits: weak_count] [15 bits: strong_count] pub struct RefCount { - strong: PyAtomic, + state: PyAtomic, } impl Default for RefCount { @@ -27,71 +81,191 @@ impl Default for RefCount { } impl RefCount { - const MASK: usize = MAX_REFCOUNT; - + /// Create a new RefCount with strong count = 1 pub fn new() -> Self { + // Initial state: strong=1, weak=1 (implicit weak for strong refs) Self { - strong: Radium::new(1), + state: Radium::new(COUNT + WEAK_COUNT), } } + /// Get current strong count #[inline] pub fn get(&self) -> usize { - self.strong.load(SeqCst) + State::from_raw(self.state.load(Ordering::SeqCst)).strong() as usize } + /// Increment strong count #[inline] pub fn inc(&self) { - let old_size = self.strong.fetch_add(1, Relaxed); - - if old_size & Self::MASK == Self::MASK { + let val = State::from_raw(self.state.fetch_add(COUNT, Ordering::SeqCst)); + if val.destructed() { refcount_overflow(); } + if val.strong() == 0 { + // The previous fetch_add created a permission to run decrement again + self.state.fetch_add(COUNT, Ordering::SeqCst); + } } #[inline] pub fn inc_by(&self, n: usize) { - debug_assert!(n <= Self::MASK); - let old_size = self.strong.fetch_add(n, Relaxed); - - if old_size & Self::MASK > Self::MASK - n { + debug_assert!(n <= STRONG); + let val = State::from_raw(self.state.fetch_add(n * COUNT, Ordering::SeqCst)); + if val.destructed() || (val.strong() as usize) > STRONG - n { refcount_overflow(); } } /// Returns true if successful #[inline] + #[must_use] pub fn safe_inc(&self) -> bool { - self.strong - .fetch_update(AcqRel, Acquire, |prev| (prev != 0).then_some(prev + 1)) - .is_ok() + let mut old = State::from_raw(self.state.load(Ordering::SeqCst)); + loop { + if old.destructed() { + return false; + } + if (old.strong() as usize) >= STRONG { + refcount_overflow(); + } + let new_state = old.add_strong(1); + match self.state.compare_exchange( + old.as_raw(), + new_state.as_raw(), + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => return true, + Err(curr) => old = State::from_raw(curr), + } + } } - /// Decrement the reference count. Returns true when the refcount drops to 0. + /// Decrement strong count. Returns true when count drops to 0. #[inline] + #[must_use] pub fn dec(&self) -> bool { - if self.strong.fetch_sub(1, Release) != 1 { + let old = State::from_raw(self.state.fetch_sub(COUNT, Ordering::SeqCst)); + + // LEAKED objects never reach 0 + if old.leaked() { return false; } - PyAtomic::::fence(Acquire); - - true + old.strong() == 1 } -} - -impl RefCount { - // move these functions out and give separated type once type range is stabilized + /// Mark this object as leaked (interned). It will never be deallocated. pub fn leak(&self) { debug_assert!(!self.is_leaked()); - const BIT_MARKER: usize = (isize::MAX as usize) + 1; - debug_assert_eq!(BIT_MARKER.count_ones(), 1); - debug_assert_eq!(BIT_MARKER.leading_zeros(), 0); - self.strong.fetch_add(BIT_MARKER, Relaxed); + let mut old = State::from_raw(self.state.load(Ordering::SeqCst)); + loop { + let new_state = old.with_leaked(true); + match self.state.compare_exchange( + old.as_raw(), + new_state.as_raw(), + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => return, + Err(curr) => old = State::from_raw(curr), + } + } } + /// Check if this object is leaked (interned). pub fn is_leaked(&self) -> bool { - (self.strong.load(Acquire) as isize) < 0 + State::from_raw(self.state.load(Ordering::Acquire)).leaked() + } +} + +// Deferred Drop Infrastructure +// +// This mechanism allows untrack_object() calls to be deferred until after +// the GC collection phase completes, preventing deadlocks that occur when +// clear (pop_edges) triggers object destruction while holding the tracked_objects lock. + +#[cfg(feature = "std")] +use core::cell::{Cell, RefCell}; + +#[cfg(feature = "std")] +thread_local! { + /// Flag indicating if we're inside a deferred drop context. + /// When true, drop operations should defer untrack calls. + static IN_DEFERRED_CONTEXT: Cell = const { Cell::new(false) }; + + /// Queue of deferred untrack operations. + /// No Send bound needed - this is thread-local and only accessed from the same thread. + static DEFERRED_QUEUE: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +#[cfg(feature = "std")] +struct DeferredDropGuard { + was_in_context: bool, +} + +#[cfg(feature = "std")] +impl Drop for DeferredDropGuard { + fn drop(&mut self) { + IN_DEFERRED_CONTEXT.with(|in_ctx| { + in_ctx.set(self.was_in_context); + }); + // Only flush if we're the outermost context + if !self.was_in_context { + flush_deferred_drops(); + } + } +} + +/// Execute a function within a deferred drop context. +/// Any calls to `try_defer_drop` within this context will be queued +/// and executed when the context exits (even on panic). +#[cfg(feature = "std")] +#[inline] +pub fn with_deferred_drops(f: F) -> R +where + F: FnOnce() -> R, +{ + let _guard = IN_DEFERRED_CONTEXT.with(|in_ctx| { + let was_in_context = in_ctx.get(); + in_ctx.set(true); + DeferredDropGuard { was_in_context } + }); + f() +} + +/// Try to defer a drop-related operation. +/// If inside a deferred context, the operation is queued. +/// Otherwise, it executes immediately. +#[cfg(feature = "std")] +#[inline] +pub fn try_defer_drop(f: F) +where + F: FnOnce() + 'static, +{ + let should_defer = IN_DEFERRED_CONTEXT.with(|in_ctx| in_ctx.get()); + + if should_defer { + DEFERRED_QUEUE.with(|q| { + q.borrow_mut().push(Box::new(f)); + }); + } else { + f(); } } + +/// Flush all deferred drop operations. +/// This is automatically called when exiting a deferred context. +#[cfg(feature = "std")] +#[inline] +pub fn flush_deferred_drops() { + DEFERRED_QUEUE.with(|q| { + // Take all queued operations + let ops: Vec<_> = q.borrow_mut().drain(..).collect(); + // Execute them outside the borrow + for op in ops { + op(); + } + }); +} diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 49257117484..9fcc27fae11 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -423,6 +423,10 @@ impl PyType { // Static types are not tracked by GC. // They are immortal and never participate in collectable cycles. + unsafe { + crate::gc_state::gc_state() + .untrack_object(core::ptr::NonNull::from(new_type.as_object())); + } new_type.as_object().clear_gc_tracked(); new_type.mro.write().insert(0, new_type.clone()); diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index 87dd1152d9c..8e1c81e6201 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -4,7 +4,7 @@ //! for RustPython, using an intrusive doubly-linked list approach. use crate::common::lock::PyMutex; -use crate::{PyObject, PyObjectRef}; +use crate::{AsObject, PyObject, PyObjectRef}; use core::ptr::NonNull; use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::collections::HashSet; @@ -28,7 +28,7 @@ bitflags::bitflags! { } /// Statistics for a single generation (gc_generation_stats) -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default)] pub struct GcStats { pub collections: usize, pub collected: usize, @@ -114,9 +114,7 @@ pub struct GcState { pub garbage: PyMutex>, /// gc.callbacks list pub callbacks: PyMutex>, - /// Mutex for collection (prevents concurrent collections). - /// Used by collect_inner when the actual collection algorithm is enabled. - #[allow(dead_code)] + /// Mutex for collection (prevents concurrent collections) collecting: Mutex<()>, /// Allocation counter for gen0 alloc_count: AtomicUsize, @@ -385,25 +383,380 @@ impl GcState { false } - /// Perform garbage collection on the given generation. - /// Returns (collected_count, uncollectable_count). + /// Perform garbage collection on the given generation + /// Returns (collected_count, uncollectable_count) /// - /// Currently a stub — the actual collection algorithm requires EBR - /// and will be added in a follow-up. - pub fn collect(&self, _generation: usize) -> (usize, usize) { - // gc_collect_main - // Reset gen0 count even though we're not actually collecting - self.generations[0].count.store(0, Ordering::SeqCst); - (0, 0) + /// Implements CPython-compatible generational GC algorithm: + /// - Only collects objects from generations 0 to `generation` + /// - Uses gc_refs algorithm: gc_refs = strong_count - internal_refs + /// - Only subtracts references between objects IN THE SAME COLLECTION + /// + /// If `force` is true, collection runs even if GC is disabled (for manual gc.collect() calls) + pub fn collect(&self, generation: usize) -> (usize, usize) { + self.collect_inner(generation, false) } - /// Force collection even if GC is disabled (for manual gc.collect() calls). - /// gc.collect() always runs regardless of gc.isenabled() - /// Currently a stub. - pub fn collect_force(&self, _generation: usize) -> (usize, usize) { - // Reset gen0 count even though we're not actually collecting + /// Force collection even if GC is disabled (for manual gc.collect() calls) + pub fn collect_force(&self, generation: usize) -> (usize, usize) { + self.collect_inner(generation, true) + } + + fn collect_inner(&self, generation: usize, force: bool) -> (usize, usize) { + if !force && !self.is_enabled() { + return (0, 0); + } + + // Try to acquire the collecting lock + let _guard = match self.collecting.try_lock() { + Ok(g) => g, + Err(_) => return (0, 0), + }; + + // Memory barrier to ensure visibility of all reference count updates + // from other threads before we start analyzing the object graph. + core::sync::atomic::fence(Ordering::SeqCst); + + let generation = generation.min(2); + let debug = self.get_debug(); + + // Step 1: Gather objects from generations 0..=generation + // Hold read locks for the entire collection to prevent other threads + // from untracking objects while we're iterating. + let gen_locks: Vec<_> = (0..=generation) + .filter_map(|i| self.generation_objects[i].read().ok()) + .collect(); + + let mut collecting: HashSet = HashSet::new(); + for gen_set in &gen_locks { + for &ptr in gen_set.iter() { + let obj = unsafe { ptr.0.as_ref() }; + if obj.strong_count() > 0 { + collecting.insert(ptr); + } + } + } + + if collecting.is_empty() { + // Reset gen0 count even if nothing to collect + self.generations[0].count.store(0, Ordering::SeqCst); + self.generations[generation].update_stats(0, 0); + return (0, 0); + } + + if debug.contains(GcDebugFlags::STATS) { + eprintln!( + "gc: collecting {} objects from generations 0..={}", + collecting.len(), + generation + ); + } + + // Step 2: Build gc_refs map (copy reference counts) + let mut gc_refs: std::collections::HashMap = + std::collections::HashMap::new(); + for &ptr in &collecting { + let obj = unsafe { ptr.0.as_ref() }; + gc_refs.insert(ptr, obj.strong_count()); + } + + // Step 3: Subtract internal references + // CRITICAL: Only subtract refs to objects IN THE COLLECTING SET + for &ptr in &collecting { + let obj = unsafe { ptr.0.as_ref() }; + // Double-check object is still alive + if obj.strong_count() == 0 { + continue; + } + let referent_ptrs = unsafe { obj.gc_get_referent_ptrs() }; + for child_ptr in referent_ptrs { + let gc_ptr = GcObjectPtr(child_ptr); + // Only decrement if child is also in the collecting set! + if collecting.contains(&gc_ptr) + && let Some(refs) = gc_refs.get_mut(&gc_ptr) + { + *refs = refs.saturating_sub(1); + } + } + } + + // Step 4: Find reachable objects (gc_refs > 0) and traverse from them + // Objects with gc_refs > 0 are definitely reachable from outside. + // We need to mark all objects reachable from them as also reachable. + let mut reachable: HashSet = HashSet::new(); + let mut worklist: Vec = Vec::new(); + + // Start with objects that have gc_refs > 0 + for (&ptr, &refs) in &gc_refs { + if refs > 0 { + reachable.insert(ptr); + worklist.push(ptr); + } + } + + // Traverse reachable objects to find more reachable ones + while let Some(ptr) = worklist.pop() { + let obj = unsafe { ptr.0.as_ref() }; + if obj.is_gc_tracked() { + let referent_ptrs = unsafe { obj.gc_get_referent_ptrs() }; + for child_ptr in referent_ptrs { + let gc_ptr = GcObjectPtr(child_ptr); + // If child is in collecting set and not yet marked reachable + if collecting.contains(&gc_ptr) && reachable.insert(gc_ptr) { + worklist.push(gc_ptr); + } + } + } + } + + // Step 5: Find unreachable objects (in collecting but not in reachable) + let unreachable: Vec = collecting.difference(&reachable).copied().collect(); + + if debug.contains(GcDebugFlags::STATS) { + eprintln!( + "gc: {} reachable, {} unreachable", + reachable.len(), + unreachable.len() + ); + } + + if unreachable.is_empty() { + // No cycles found - promote survivors to next generation + drop(gen_locks); // Release read locks before promoting + self.promote_survivors(generation, &collecting); + // Reset gen0 count + self.generations[0].count.store(0, Ordering::SeqCst); + self.generations[generation].update_stats(0, 0); + return (0, 0); + } + + // Release read locks before finalization phase. + // This allows other threads to untrack objects while we finalize. + drop(gen_locks); + + // Step 6: Finalize unreachable objects and handle resurrection + + // 6a: Get references to all unreachable objects + let unreachable_refs: Vec = unreachable + .iter() + .filter_map(|ptr| { + let obj = unsafe { ptr.0.as_ref() }; + if obj.strong_count() > 0 { + Some(obj.to_owned()) + } else { + None + } + }) + .collect(); + + if unreachable_refs.is_empty() { + self.promote_survivors(generation, &reachable); + // Reset gen0 count + self.generations[0].count.store(0, Ordering::SeqCst); + self.generations[generation].update_stats(0, 0); + return (0, 0); + } + + // 6b: Record initial strong counts (for resurrection detection) + // Each object has +1 from unreachable_refs, so initial count includes that + let initial_counts: std::collections::HashMap = unreachable_refs + .iter() + .map(|obj| { + let ptr = GcObjectPtr(core::ptr::NonNull::from(obj.as_ref())); + (ptr, obj.strong_count()) + }) + .collect(); + + // 6c: Clear existing weakrefs BEFORE calling __del__ + // This invalidates existing weakrefs, but new weakrefs created during __del__ + // will still work (WeakRefList::add restores inner.obj if cleared) + // + // CRITICAL: We use a two-phase approach to match CPython behavior: + // Phase 1: Clear ALL weakrefs (set inner.obj = None) and collect callbacks + // Phase 2: Invoke ALL callbacks + // This ensures that when a callback runs, ALL weakrefs to unreachable objects + // are already dead (return None when called). + let mut all_callbacks: Vec<(crate::PyRef, crate::PyObjectRef)> = + Vec::new(); + for obj_ref in &unreachable_refs { + let callbacks = obj_ref.gc_clear_weakrefs_collect_callbacks(); + all_callbacks.extend(callbacks); + } + // Phase 2: Now call all callbacks - at this point ALL weakrefs are cleared + for (wr, cb) in all_callbacks { + if let Some(Err(e)) = crate::vm::thread::with_vm(&cb, |vm| cb.call((wr.clone(),), vm)) { + // Report the exception via run_unraisable + crate::vm::thread::with_vm(&cb, |vm| { + vm.run_unraisable(e.clone(), Some("weakref callback".to_owned()), cb.clone()); + }); + } + // If with_vm returns None, we silently skip - no VM available to handle errors + } + + // 6d: Call __del__ on all unreachable objects + // This allows resurrection to work correctly + // Skip objects that have already been finalized (prevents multiple __del__ calls) + for obj_ref in &unreachable_refs { + let ptr = GcObjectPtr(core::ptr::NonNull::from(obj_ref.as_ref())); + let already_finalized = if let Ok(finalized) = self.finalized_objects.read() { + finalized.contains(&ptr) + } else { + false + }; + + if !already_finalized { + // Mark as finalized BEFORE calling __del__ + // This ensures is_finalized() returns True inside __del__ + if let Ok(mut finalized) = self.finalized_objects.write() { + finalized.insert(ptr); + } + obj_ref.try_call_finalizer(); + } + } + + // 6d: Detect resurrection - strong_count increased means object was resurrected + // Step 1: Find directly resurrected objects (strong_count increased) + let mut resurrected_set: HashSet = HashSet::new(); + let unreachable_set: HashSet = unreachable.iter().copied().collect(); + + for obj in &unreachable_refs { + let ptr = GcObjectPtr(core::ptr::NonNull::from(obj.as_ref())); + let initial = initial_counts.get(&ptr).copied().unwrap_or(1); + if obj.strong_count() > initial { + resurrected_set.insert(ptr); + } + } + + // Step 2: Transitive resurrection - objects reachable from resurrected are also resurrected + // This is critical for cases like: Lazarus resurrects itself, its cargo should also survive + let mut worklist: Vec = resurrected_set.iter().copied().collect(); + while let Some(ptr) = worklist.pop() { + let obj = unsafe { ptr.0.as_ref() }; + let referent_ptrs = unsafe { obj.gc_get_referent_ptrs() }; + for child_ptr in referent_ptrs { + let child_gc_ptr = GcObjectPtr(child_ptr); + // If child is in unreachable set and not yet marked as resurrected + if unreachable_set.contains(&child_gc_ptr) && resurrected_set.insert(child_gc_ptr) { + worklist.push(child_gc_ptr); + } + } + } + + // Step 3: Partition into resurrected and truly dead + let (resurrected, truly_dead): (Vec<_>, Vec<_>) = + unreachable_refs.into_iter().partition(|obj| { + let ptr = GcObjectPtr(core::ptr::NonNull::from(obj.as_ref())); + resurrected_set.contains(&ptr) + }); + + let resurrected_count = resurrected.len(); + + if debug.contains(GcDebugFlags::STATS) { + eprintln!( + "gc: {} resurrected, {} truly dead", + resurrected_count, + truly_dead.len() + ); + } + + // 6e: Break cycles ONLY for truly dead objects (not resurrected) + // Compute collected count: exclude instance dicts that are also in truly_dead. + // In CPython 3.12+, instance dicts are managed inline and not separately tracked, + // so they don't count toward the collected total. + let collected = { + let dead_ptrs: HashSet = truly_dead + .iter() + .map(|obj| obj.as_ref() as *const PyObject as usize) + .collect(); + let instance_dict_count = truly_dead + .iter() + .filter(|obj| { + if let Some(dict_ref) = obj.dict() { + dead_ptrs.contains(&(dict_ref.as_object() as *const PyObject as usize)) + } else { + false + } + }) + .count(); + truly_dead.len() - instance_dict_count + }; + + // 6e-1: If DEBUG_SAVEALL is set, save truly dead objects to garbage + if debug.contains(GcDebugFlags::SAVEALL) { + let mut garbage_guard = self.garbage.lock(); + for obj_ref in truly_dead.iter() { + garbage_guard.push(obj_ref.clone()); + } + } + + if !truly_dead.is_empty() { + // 6g: Break cycles by clearing references (tp_clear) + // Weakrefs were already cleared in step 6c, but new weakrefs created + // during __del__ (step 6d) can still be upgraded. + // + // Clear and destroy objects within a deferred drop context. + // This prevents deadlocks from untrack calls during destruction. + rustpython_common::refcount::with_deferred_drops(|| { + for obj_ref in truly_dead.iter() { + if obj_ref.gc_has_clear() { + let edges = unsafe { obj_ref.gc_clear() }; + drop(edges); + } + } + // Drop truly_dead references, triggering actual deallocation + drop(truly_dead); + }); + } + + // 6f: Resurrected objects stay in tracked_objects (they're still alive) + // Just drop our references to them + drop(resurrected); + + // Promote survivors (reachable objects) to next generation + self.promote_survivors(generation, &reachable); + + // Reset gen0 count after collection (enables automatic GC to trigger again) self.generations[0].count.store(0, Ordering::SeqCst); - (0, 0) + + self.generations[generation].update_stats(collected, 0); + + (collected, 0) + } + + /// Promote surviving objects to the next generation + fn promote_survivors(&self, from_gen: usize, survivors: &HashSet) { + if from_gen >= 2 { + return; // Already in oldest generation + } + + let next_gen = from_gen + 1; + + for &ptr in survivors { + // Remove from current generation + for gen_idx in 0..=from_gen { + if let Ok(mut gen_set) = self.generation_objects[gen_idx].write() + && gen_set.remove(&ptr) + { + // Decrement count for source generation + let count = self.generations[gen_idx].count.load(Ordering::SeqCst); + if count > 0 { + self.generations[gen_idx] + .count + .fetch_sub(1, Ordering::SeqCst); + } + + // Add to next generation + if let Ok(mut next_set) = self.generation_objects[next_gen].write() + && next_set.insert(ptr) + { + // Increment count for target generation + self.generations[next_gen] + .count + .fetch_add(1, Ordering::SeqCst); + } + break; + } + } + } } /// Get count of frozen objects diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index b238835d28d..73b7a4d4815 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -89,11 +89,23 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { return; // resurrected by __del__ } - // Extract child references before deallocation to break circular refs (tp_clear). - // This ensures that when edges are dropped after the object is freed, - // any pointers back to this object are already gone. + let vtable = obj_ref.0.vtable; + + // Untrack from GC BEFORE deallocation. + if obj_ref.is_gc_tracked() { + let ptr = unsafe { NonNull::new_unchecked(obj) }; + rustpython_common::refcount::try_defer_drop(move || { + // untrack_object only removes the pointer address from a HashSet. + // It does NOT dereference the pointer, so it's safe even after deallocation. + unsafe { + crate::gc_state::gc_state().untrack_object(ptr); + } + }); + } + + // Extract child references before deallocation to break circular refs (tp_clear) let mut edges = Vec::new(); - if let Some(clear_fn) = obj_ref.0.vtable.clear { + if let Some(clear_fn) = vtable.clear { unsafe { clear_fn(obj, &mut edges) }; } @@ -316,20 +328,24 @@ impl WeakRefList { dict: Option, ) -> PyRef { let is_generic = cls_is_weakref && callback.is_none(); - let _lock = weakref_lock::lock(obj as *const PyObject as usize); - // try_reuse_basic_ref: reuse cached generic weakref - if is_generic { - let generic_ptr = self.generic.load(Ordering::Relaxed); - if !generic_ptr.is_null() { - let generic = unsafe { &*generic_ptr }; - if generic.0.ref_count.safe_inc() { - return unsafe { PyRef::from_raw(generic_ptr) }; + // Try reuse under lock first (fast path, no allocation) + { + let _lock = weakref_lock::lock(obj as *const PyObject as usize); + if is_generic { + let generic_ptr = self.generic.load(Ordering::Relaxed); + if !generic_ptr.is_null() { + let generic = unsafe { &*generic_ptr }; + if generic.0.ref_count.safe_inc() { + return unsafe { PyRef::from_raw(generic_ptr) }; + } } } } - // Allocate new PyWeak with wr_object pointing to referent + // Allocate OUTSIDE the stripe lock. PyRef::new_ref may trigger + // maybe_collect → GC → WeakRefList::clear on another object that + // hashes to the same stripe, which would deadlock on the spinlock. let weak_payload = PyWeak { pointers: Pointers::new(), wr_object: Radium::new(obj as *const PyObject as *mut PyObject), @@ -338,6 +354,9 @@ impl WeakRefList { }; let weak = PyRef::new_ref(weak_payload, cls, dict); + // Re-acquire lock for linked list insertion + let _lock = weakref_lock::lock(obj as *const PyObject as usize); + // Insert into linked list under stripe lock let node_ptr = NonNull::from(&*weak); unsafe { @@ -1070,7 +1089,7 @@ impl PyObject { // Undo the temporary resurrection. Always remove both // temporary refs; the second dec returns true only when // ref_count drops to 0 (no resurrection). - zelf.0.ref_count.dec(); + let _ = zelf.0.ref_count.dec(); zelf.0.ref_count.dec() }); match ret { @@ -1147,7 +1166,7 @@ impl PyObject { /// Call __del__ if present, without triggering object deallocation. /// Used by GC to call finalizers before breaking cycles. /// This allows proper resurrection detection. - /// CPython: PyObject_CallFinalizerFromDealloc in Objects/object.c + /// PyObject_CallFinalizerFromDealloc pub fn try_call_finalizer(&self) { let del = self.class().slots.del.load(); if let Some(slot_del) = del diff --git a/crates/vm/src/signal.rs b/crates/vm/src/signal.rs index f146a7321a0..ede037c3791 100644 --- a/crates/vm/src/signal.rs +++ b/crates/vm/src/signal.rs @@ -51,6 +51,7 @@ pub fn check_signals(vm: &VirtualMachine) -> PyResult<()> { trigger_signals(vm) } + #[inline(never)] #[cold] fn trigger_signals(vm: &VirtualMachine) -> PyResult<()> { diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index a71a9c08157..f6ce3448c03 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -387,11 +387,12 @@ impl Interpreter { /// Finalization steps (matching Py_FinalizeEx): /// 1. Flush stdout and stderr. /// 1. Handle exit exception and turn it to exit code. - /// 1. Wait for thread shutdown (call threading._shutdown). - /// 1. Mark vm as finalizing. + /// 1. Call threading._shutdown() to join non-daemon threads. /// 1. Run atexit exit functions. - /// 1. Finalize modules (clear module dicts in reverse import order). - /// 1. Mark vm as finalized. + /// 1. Set finalizing flag (suppresses unraisable exceptions from __del__). + /// 1. Forced GC collection pass (collect cycles while builtins are available). + /// 1. Module finalization (finalize_modules). + /// 1. Final stdout/stderr flush. /// /// Note that calling `finalize` is not necessary by purpose though. pub fn finalize(self, exc: Option) -> u32 { @@ -419,13 +420,19 @@ impl Interpreter { ); } - // Mark as finalizing AFTER thread shutdown + // Run atexit handlers before setting finalizing flag. + // This allows unraisable exceptions from atexit handlers to be reported. + atexit::_run_exitfuncs(vm); + + // Now suppress unraisable exceptions from daemon threads and __del__ + // methods during the rest of shutdown. vm.state.finalizing.store(true, Ordering::Release); - // Run atexit exit functions - atexit::_run_exitfuncs(vm); + // GC pass - collect cycles before module cleanup + crate::gc_state::gc_state().collect_force(2); - // Finalize modules: clear module dicts in reverse import order + // Module finalization: remove modules from sys.modules, GC collect + // (while builtins is still available for __del__), then clear module dicts. vm.finalize_modules(); vm.flush_std(); diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 50110325d32..3285885eea2 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -730,26 +730,25 @@ impl VirtualMachine { // Phase 2: Remove all modules from sys.modules (set values to None), // and collect weakrefs to modules preserving import order. - // Also keeps strong refs (module_refs) to prevent premature deallocation. - // CPython uses _PyGC_CollectNoFail() here to collect __globals__ cycles; - // since RustPython has no working GC, we keep modules alive through - // Phase 4 so their dicts can be explicitly cleared. - let (module_weakrefs, module_refs) = self.finalize_remove_modules(); + // No strong refs are kept — modules freed when their last ref drops. + let module_weakrefs = self.finalize_remove_modules(); // Phase 3: Clear sys.modules dict self.finalize_clear_modules_dict(); - // Phase 4: Clear module dicts in reverse import order using 2-pass algorithm. - // All modules are still alive (held by module_refs), so all weakrefs are valid. - // This breaks __globals__ cycles: dict entries set to None → functions freed → - // __globals__ refs dropped → dict refcount decreases. + // Phase 4: GC collect — modules removed from sys.modules are freed, + // exposing cycles (e.g., dict ↔ function.__globals__). GC collects + // these and calls __del__ while module dicts are still intact. + crate::gc_state::gc_state().collect_force(2); + + // Phase 5: Clear module dicts in reverse import order using 2-pass algorithm. + // Skip builtins and sys — those are cleared last. self.finalize_clear_module_dicts(&module_weakrefs); - // Drop strong refs → modules freed with already-cleared dicts. - // No __globals__ cycles remain (broken by Phase 4). - drop(module_refs); + // Phase 6: GC collect — pick up anything freed by dict clearing. + crate::gc_state::gc_state().collect_force(2); - // Phase 5: Clear sys and builtins dicts last + // Phase 7: Clear sys and builtins dicts last self.finalize_clear_sys_builtins_dict(); } @@ -793,17 +792,17 @@ impl VirtualMachine { let _ = self.builtins.dict().set_item("_", none, self); } - /// Phase 2: Set all sys.modules values to None and collect weakrefs to modules. - /// Returns (weakrefs for Phase 4, strong refs to keep modules alive). - fn finalize_remove_modules(&self) -> (Vec<(String, PyRef)>, Vec) { + /// Phase 2: Set all sys.modules values to None and collect weakrefs. + /// No strong refs are kept — modules are freed when removed from sys.modules + /// (if nothing else references them), allowing GC to collect their cycles. + fn finalize_remove_modules(&self) -> Vec<(String, PyRef)> { let mut module_weakrefs = Vec::new(); - let mut module_refs = Vec::new(); let Ok(modules) = self.sys_module.get_attr(identifier!(self, modules), self) else { - return (module_weakrefs, module_refs); + return module_weakrefs; }; let Some(modules_dict) = modules.downcast_ref::() else { - return (module_weakrefs, module_refs); + return module_weakrefs; }; let none = self.ctx.none(); @@ -815,19 +814,18 @@ impl VirtualMachine { .map(|s| s.as_str().to_owned()) .unwrap_or_default(); - // Save weakref and strong ref to module for later clearing - if value.downcast_ref::().is_some() { - if let Ok(weak) = value.downgrade(None, self) { - module_weakrefs.push((name, weak)); - } - module_refs.push(value.clone()); + // Save weakref to module (for later dict clearing) + if value.downcast_ref::().is_some() + && let Ok(weak) = value.downgrade(None, self) + { + module_weakrefs.push((name, weak)); } // Set the value to None in sys.modules let _ = modules_dict.set_item(&*key, none.clone(), self); } - (module_weakrefs, module_refs) + module_weakrefs } /// Phase 3: Clear sys.modules dict. @@ -839,18 +837,13 @@ impl VirtualMachine { } } - /// Phase 4: Clear module dicts in reverse import order using 2-pass algorithm. - /// Without GC, only clear __main__ — other modules' __del__ handlers - /// need their globals intact. CPython can clear ALL module dicts because - /// _PyGC_CollectNoFail() finalizes cycle-participating objects beforehand. + /// Phase 5: Clear module dicts in reverse import order. + /// Skip builtins and sys — those are cleared last in Phase 7. fn finalize_clear_module_dicts(&self, module_weakrefs: &[(String, PyRef)]) { - for (name, weakref) in module_weakrefs.iter().rev() { - // Only clear __main__ — user objects with __del__ get finalized - // while other modules' globals remain intact for their __del__ handlers. - if name != "__main__" { - continue; - } + let builtins_dict = self.builtins.dict(); + let sys_dict = self.sys_module.dict(); + for (_name, weakref) in module_weakrefs.iter().rev() { let Some(module_obj) = weakref.upgrade() else { continue; }; @@ -858,7 +851,13 @@ impl VirtualMachine { continue; }; - Self::module_clear_dict(&module.dict(), self); + let dict = module.dict(); + // Skip builtins and sys — they are cleared last + if dict.is(&builtins_dict) || dict.is(&sys_dict) { + continue; + } + + Self::module_clear_dict(&dict, self); } } @@ -875,7 +874,7 @@ impl VirtualMachine { } if let Some(key_str) = key.downcast_ref::() { let name = key_str.as_str(); - if name.starts_with('_') && name != "__builtins__" && name != "__spec__" { + if name.starts_with('_') && name != "__builtins__" { let _ = dict.set_item(name, none.clone(), vm); } } @@ -888,14 +887,13 @@ impl VirtualMachine { } if let Some(key_str) = key.downcast_ref::() && key_str.as_str() != "__builtins__" - && key_str.as_str() != "__spec__" { let _ = dict.set_item(key_str.as_str(), none.clone(), vm); } } } - /// Phase 5: Clear sys and builtins dicts last. + /// Phase 7: Clear sys and builtins dicts last. fn finalize_clear_sys_builtins_dict(&self) { Self::module_clear_dict(&self.sys_module.dict(), self); Self::module_clear_dict(&self.builtins.dict(), self); @@ -1148,6 +1146,7 @@ impl VirtualMachine { self.frames.borrow_mut().pop(); #[cfg(feature = "threading")] crate::vm::thread::pop_thread_frame(); + self.recursion_depth.update(|d| d - 1); } diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 575910f7900..e7c5da63037 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -43,6 +43,7 @@ thread_local! { /// while the owning thread is writing). pub(crate) static CURRENT_FRAME: AtomicPtr = const { AtomicPtr::new(core::ptr::null_mut()) }; + } scoped_tls::scoped_thread_local!(static VM_CURRENT: VirtualMachine); From d479d0019609ca9c2c8afb28bd8675f84af308cc Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sat, 21 Feb 2026 17:16:54 +0900 Subject: [PATCH 3/4] trashcan --- Lib/test/test_asyncio/test_ssl.py | 1 - Lib/test/test_descr.py | 1 - Lib/test/test_faulthandler.py | 1 - Lib/test/test_functools.py | 1 - Lib/test/test_inspect/test_inspect.py | 1 - Lib/test/test_io.py | 4 -- Lib/test/test_logging.py | 2 - Lib/test/test_memoryio.py | 1 - Lib/test/test_ordered_dict.py | 1 - Lib/test/test_subprocess.py | 1 - Lib/test/test_threading.py | 2 - Lib/test/test_traceback.py | 1 - Lib/test/test_warnings/__init__.py | 1 - crates/vm/src/object/core.rs | 77 +++++++++++++++++++++++++++ 14 files changed, 77 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_asyncio/test_ssl.py b/Lib/test/test_asyncio/test_ssl.py index e5d3b63b94f..ab4a2316f9c 100644 --- a/Lib/test/test_asyncio/test_ssl.py +++ b/Lib/test/test_asyncio/test_ssl.py @@ -1649,7 +1649,6 @@ async def test(ctx): # SSLProtocol should be DECREF to 0 self.assertIsNone(ctx()) - @unittest.expectedFailure # TODO: RUSTPYTHON; - gc.collect() doesn't release SSLContext properly def test_shutdown_timeout_handler_leak(self): loop = self.loop diff --git a/Lib/test/test_descr.py b/Lib/test/test_descr.py index 0d461297936..c948d156cdb 100644 --- a/Lib/test/test_descr.py +++ b/Lib/test/test_descr.py @@ -5117,7 +5117,6 @@ def __new__(cls): cls.lst = [2**i for i in range(10000)] X.descr - @unittest.expectedFailure # TODO: RUSTPYTHON def test_remove_subclass(self): # bpo-46417: when the last subclass of a type is deleted, # remove_subclass() clears the internal dictionary of subclasses: diff --git a/Lib/test/test_faulthandler.py b/Lib/test/test_faulthandler.py index 090fb3a1484..f498085c691 100644 --- a/Lib/test/test_faulthandler.py +++ b/Lib/test/test_faulthandler.py @@ -199,7 +199,6 @@ def test_sigsegv(self): 3, 'Segmentation fault') - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '(?m)^Fatal Python error: Segmentation fault\n\n\nStack\\ \\(most\\ recent\\ call\\ first\\):\n File "", line 9 in __del__\nCurrent thread\'s C stack trace \\(most recent call first\\):\n( Binary file ".+"(, at .*(\\+|-)0x[0-9a-f]+)? \\[0x[0-9a-f]+\\])|(<.+>)' not found in 'exit' @skip_segfault_on_android def test_gc(self): # bpo-44466: Detect if the GC is running diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 21b94cfd2bc..c5218be0673 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2105,7 +2105,6 @@ def f(): return 1 self.assertEqual(f.cache_parameters(), {'maxsize': 1000, "typed": True}) - @unittest.expectedFailure # TODO: RUSTPYTHON; GC behavior differs from CPython's refcounting def test_lru_cache_weakrefable(self): @self.module.lru_cache def test_function(x): diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 1c4a1c6bd42..77a160bcf19 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -2714,7 +2714,6 @@ def __getattribute__(self, attr): self.assertFalse(test.called) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: .Foo'> is not None def test_cache_does_not_cause_classes_to_persist(self): # regression test for gh-118013: # check that the internal _shadowed_dict cache does not cause diff --git a/Lib/test/test_io.py b/Lib/test/test_io.py index e747b9dd03f..2324c447f99 100644 --- a/Lib/test/test_io.py +++ b/Lib/test/test_io.py @@ -3813,7 +3813,6 @@ def __del__(self): """.format(iomod=iomod, kwargs=kwargs) return assert_python_ok("-c", code) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError during module teardown in __del__ def test_create_at_shutdown_without_encoding(self): rc, out, err = self._check_create_at_shutdown() if err: @@ -3823,7 +3822,6 @@ def test_create_at_shutdown_without_encoding(self): else: self.assertEqual("ok", out.decode().strip()) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError during module teardown in __del__ def test_create_at_shutdown_with_encoding(self): rc, out, err = self._check_create_at_shutdown(encoding='utf-8', errors='strict') @@ -4812,13 +4810,11 @@ def run(): else: self.assertFalse(err.strip('.!')) - @unittest.expectedFailure # TODO: RUSTPYTHON; without GC+GIL, finalize_modules clears __main__ globals while daemon threads are still running @threading_helper.requires_working_threading() @support.requires_resource('walltime') def test_daemon_threads_shutdown_stdout_deadlock(self): self.check_daemon_threads_shutdown_deadlock('stdout') - @unittest.expectedFailure # TODO: RUSTPYTHON; without GC+GIL, finalize_modules clears __main__ globals while daemon threads are still running @threading_helper.requires_working_threading() @support.requires_resource('walltime') def test_daemon_threads_shutdown_stderr_deadlock(self): diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index 6c0cb49f78b..9a4543e6195 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -5165,7 +5165,6 @@ def __init__(self, name='MyLogger', level=logging.NOTSET): h.close() logging.setLoggerClass(logging.Logger) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError during module teardown in __del__ def test_logging_at_shutdown(self): # bpo-20037: Doing text I/O late at interpreter shutdown must not crash code = textwrap.dedent(""" @@ -5185,7 +5184,6 @@ def __del__(self): self.assertIn("exception in __del__", err) self.assertIn("ValueError: some error", err) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError during module teardown in __del__ def test_logging_at_shutdown_open(self): # bpo-26789: FileHandler keeps a reference to the builtin open() # function to be able to open or reopen the file during Python diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 00f646e5a94..7b321600e88 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -485,7 +485,6 @@ def test_getbuffer_empty(self): buf2.release() memio.write(b'x') - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: is not None def test_getbuffer_gc_collect(self): memio = self.ioclass(b"1234567890") buf = memio.getbuffer() diff --git a/Lib/test/test_ordered_dict.py b/Lib/test/test_ordered_dict.py index 5d1ae680e30..378f6c5ab59 100644 --- a/Lib/test/test_ordered_dict.py +++ b/Lib/test/test_ordered_dict.py @@ -669,7 +669,6 @@ def test_dict_update(self): dict.update(od, [('spam', 1)]) self.assertNotIn('NULL', repr(od)) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: .A'> is not None def test_reference_loop(self): # Issue 25935 OrderedDict = self.OrderedDict diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index aaac2447942..de0cde12a82 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -3557,7 +3557,6 @@ def test_communicate_repeated_call_after_stdout_close(self): except subprocess.TimeoutExpired: pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: b'preexec_fn not supported at interpreter shutdown' not found in b"Exception ignored in: \nAttributeError: 'NoneType' object has no attribute 'Popen'\n" def test_preexec_at_exit(self): code = f"""if 1: import atexit diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py index 4d799d968a8..b97ec0101ca 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -854,7 +854,6 @@ def func(lock): def test_main_thread_after_fork_from_dummy_thread(self, create_dummy=False): self.test_main_thread_after_fork_from_foreign_thread(create_dummy=True) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_main_thread_during_shutdown(self): # bpo-31516: current_thread() should still point to the main thread # at shutdown @@ -1086,7 +1085,6 @@ def checker(): self.assertEqual(threading.getprofile(), old_profile) self.assertEqual(sys.getprofile(), old_profile) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_locals_at_exit(self): # bpo-19466: thread locals must not be deleted before destructors # are called diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 8b7938e3283..e904c149d03 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -472,7 +472,6 @@ def do_test(firstlines, message, charset, lineno): # Issue #18960: coding spec should have no effect do_test("x=0\n# coding: GBK\n", "h\xe9 ho", 'utf-8', 5) - @unittest.expectedFailure # TODO: RUSTPYTHON; + b'ZeroDivisionError: division by zero'] def test_print_traceback_at_exit(self): # Issue #22599: Ensure that it is possible to use the traceback module # to display an exception at Python exit diff --git a/Lib/test/test_warnings/__init__.py b/Lib/test/test_warnings/__init__.py index 53ac0363a3c..71dc20d0b59 100644 --- a/Lib/test/test_warnings/__init__.py +++ b/Lib/test/test_warnings/__init__.py @@ -1610,7 +1610,6 @@ def test_issue_8766(self): class FinalizationTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; - TypeError: 'NoneType' object is not callable def test_finalization(self): # Issue #19421: warnings.warn() should not crash # during Python finalization diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 73b7a4d4815..ffecaba1a88 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -81,6 +81,75 @@ use core::{ #[derive(Debug)] pub(super) struct Erased; +/// Trashcan mechanism to limit recursive deallocation depth (Py_TRASHCAN). +/// Without this, deeply nested structures (e.g. 200k-deep list) cause stack overflow +/// during deallocation because each level adds a stack frame. +mod trashcan { + use core::cell::Cell; + + /// Maximum nesting depth for deallocation before deferring. + /// CPython uses UNWIND_NO_NESTING = 50. + const TRASHCAN_LIMIT: usize = 50; + + type DeallocFn = unsafe fn(*mut super::PyObject); + type DeallocQueue = Vec<(*mut super::PyObject, DeallocFn)>; + + thread_local! { + static DEALLOC_DEPTH: Cell = const { Cell::new(0) }; + static DEALLOC_QUEUE: Cell = const { Cell::new(Vec::new()) }; + } + + /// Try to begin deallocation. Returns true if we should proceed, + /// false if the object was deferred (depth exceeded). + #[inline] + pub(super) unsafe fn begin( + obj: *mut super::PyObject, + dealloc: unsafe fn(*mut super::PyObject), + ) -> bool { + DEALLOC_DEPTH.with(|d| { + let depth = d.get(); + if depth >= TRASHCAN_LIMIT { + // Depth exceeded: defer this deallocation + DEALLOC_QUEUE.with(|q| { + let mut queue = q.take(); + queue.push((obj, dealloc)); + q.set(queue); + }); + false + } else { + d.set(depth + 1); + true + } + }) + } + + /// End deallocation and process any deferred objects if at outermost level. + #[inline] + pub(super) unsafe fn end() { + let depth = DEALLOC_DEPTH.with(|d| { + let depth = d.get() - 1; + d.set(depth); + depth + }); + if depth == 0 { + // Process deferred deallocations iteratively + loop { + let next = DEALLOC_QUEUE.with(|q| { + let mut queue = q.take(); + let item = queue.pop(); + q.set(queue); + item + }); + if let Some((obj, dealloc)) = next { + unsafe { dealloc(obj) }; + } else { + break; + } + } + } + } +} + /// Default dealloc: handles __del__, weakref clearing, tp_clear, and memory free. /// Equivalent to subtype_dealloc. pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { @@ -89,6 +158,11 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { return; // resurrected by __del__ } + // Trashcan: limit recursive deallocation depth to prevent stack overflow + if !unsafe { trashcan::begin(obj, default_dealloc::) } { + return; // deferred to queue + } + let vtable = obj_ref.0.vtable; // Untrack from GC BEFORE deallocation. @@ -115,6 +189,9 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { // Drop child references - may trigger recursive destruction. // The object is already deallocated, so circular refs are broken. drop(edges); + + // Trashcan: decrement depth and process deferred objects at outermost level + unsafe { trashcan::end() }; } pub(super) unsafe fn debug_obj( x: &PyObject, From be29d7c0eb01490ea32f8010444e401c9ea60d72 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Sun, 22 Feb 2026 16:49:38 +0900 Subject: [PATCH 4/4] add overflow guard to inc(), #[must_use] on dec()/safe_inc(), trashcan debug_assert, weakref generic re-check --- crates/common/src/refcount.rs | 7 ++++--- crates/vm/src/object/core.rs | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/common/src/refcount.rs b/crates/common/src/refcount.rs index 5ba4ec5277c..9c350b5497a 100644 --- a/crates/common/src/refcount.rs +++ b/crates/common/src/refcount.rs @@ -99,7 +99,7 @@ impl RefCount { #[inline] pub fn inc(&self) { let val = State::from_raw(self.state.fetch_add(COUNT, Ordering::SeqCst)); - if val.destructed() { + if val.destructed() || (val.strong() as usize) > STRONG - 1 { refcount_overflow(); } if val.strong() == 0 { @@ -211,8 +211,9 @@ impl Drop for DeferredDropGuard { IN_DEFERRED_CONTEXT.with(|in_ctx| { in_ctx.set(self.was_in_context); }); - // Only flush if we're the outermost context - if !self.was_in_context { + // Only flush if we're the outermost context and not already panicking + // (flushing during unwinding risks double-panic → process abort). + if !self.was_in_context && !std::thread::panicking() { flush_deferred_drops(); } } diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index ffecaba1a88..ea661103c39 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -127,7 +127,9 @@ mod trashcan { #[inline] pub(super) unsafe fn end() { let depth = DEALLOC_DEPTH.with(|d| { - let depth = d.get() - 1; + let depth = d.get(); + debug_assert!(depth > 0, "trashcan::end called without matching begin"); + let depth = depth - 1; d.set(depth); depth }); @@ -434,6 +436,21 @@ impl WeakRefList { // Re-acquire lock for linked list insertion let _lock = weakref_lock::lock(obj as *const PyObject as usize); + // Re-check: another thread may have inserted a generic ref while we + // were allocating outside the lock. If so, reuse it and drop ours. + if is_generic { + let generic_ptr = self.generic.load(Ordering::Relaxed); + if !generic_ptr.is_null() { + let generic = unsafe { &*generic_ptr }; + if generic.0.ref_count.safe_inc() { + // Nullify wr_object so drop_inner won't unlink an + // un-inserted node (which would corrupt the list head). + weak.wr_object.store(ptr::null_mut(), Ordering::Relaxed); + return unsafe { PyRef::from_raw(generic_ptr) }; + } + } + } + // Insert into linked list under stripe lock let node_ptr = NonNull::from(&*weak); unsafe {