Visitar URL original
more algorithm-independent GC infra by youknowone · Pull Request #7194 · RustPython/RustPython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
GC-infra independent to EBR
  • Loading branch information
youknowone committed Feb 22, 2026
commit df8b67df26ab611daa4e475f15d319abcf3a4df5
2 changes: 2 additions & 0 deletions .cspell.dict/cpython.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ uncollectable
Unhandle
unparse
unparser
untracking
VARKEYWORDS
varkwarg
venvlauncher
Expand All @@ -209,5 +210,6 @@ webpki
winconsoleio
withitem
withs
worklist
xstat
XXPRIME
3 changes: 3 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"emscripten",
"excs",
"finalizer",
"finalizers",
"GetSet",
"groupref",
"internable",
Expand Down Expand Up @@ -122,12 +123,14 @@
"tracebacks",
"typealiases",
"typevartuples",
"uncollectable",
"unhashable",
"uninit",
"unraisable",
"unresizable",
"varint",
"wasi",
"weaked",
"zelf",
// unix
"posixshmem",
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 0 additions & 7 deletions Lib/test/support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions Lib/test/test_weakref.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,6 @@ def test_init(self):
# No exception should be raised here
gc.collect()

@unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: <class 'test.test_weakref.ReferencesTestCase.test_classes.<locals>.A'> != None
def test_classes(self):
# Check that classes are weakrefable.
class A(object):
Expand Down Expand Up @@ -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))

Expand Down
2 changes: 0 additions & 2 deletions Lib/test/test_weakset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
246 changes: 210 additions & 36 deletions crates/common/src/refcount.rs
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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]
Expand All @@ -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<usize>,
state: PyAtomic<usize>,
}

impl Default for RefCount {
Expand All @@ -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);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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),
}
}
Comment on lines 120 to +142

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard safe_inc() against strong-count overflow.

safe_inc() can overflow into the weak bits when the strong count is at max; align it with inc_by()’s overflow checks.

♻️ Suggested fix
 pub fn safe_inc(&self) -> bool {
     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,
         ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/common/src/refcount.rs` around lines 120 - 138, safe_inc currently
increments without checking strong-count overflow and can wrap into weak bits;
before attempting the compare_exchange in safe_inc, detect the overflow the same
way inc_by does: read old via State::from_raw(self.state.load(...)), and if
adding 1 would exceed the maximum strong count (e.g., compare old.strong()
against State's max or check that old.add_strong(1) would overflow/wrap), return
false; only proceed to compute new_state = old.add_strong(1) and call
self.state.compare_exchange(old.as_raw(), new_state.as_raw(), ...) when the
increment is safe and old.destructed() is false. Ensure you reference
State::from_raw, destructed, add_strong, as_raw and the existing
compare_exchange call when making the change.

}

/// 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::<usize>::fence(Acquire);

true
old.strong() == 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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<bool> = 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<Vec<Box<dyn FnOnce()>>> = const { RefCell::new(Vec::new()) };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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();
}
}
}
Comment on lines +208 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

flush_deferred_drops() called from drop() during unwinding risks double-panic → process abort.

If the closure passed to with_deferred_drops panics, DeferredDropGuard::drop runs during stack unwinding and unconditionally calls flush_deferred_drops(). Should any queued operation also panic, Rust's "panic while panicking" rule causes an unconditional process abort, losing all panic context.

🛡️ Proposed fix — skip flush during unwinding
 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 {
+        if !self.was_in_context && !std::thread::panicking() {
             flush_deferred_drops();
         }
     }
 }

Skipping the flush during unwinding means deferred untrack operations will be lost on panic, which is acceptable given GC state is already inconsistent in a panic scenario.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/common/src/refcount.rs` around lines 208 - 219, The Drop impl for
DeferredDropGuard currently always calls flush_deferred_drops() which can panic
during unwinding; update DeferredDropGuard::drop to check
std::panic::panicking() (or equivalent) after restoring
IN_DEFERRED_CONTEXT.with(|in_ctx| in_ctx.set(self.was_in_context)); only call
flush_deferred_drops() when !self.was_in_context && !std::panic::panicking() so
flush is skipped during unwinding; keep restoring IN_DEFERRED_CONTEXT
unconditionally and do not change other logic.


/// 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, R>(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: 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();
}
});
Comment on lines +263 to +271

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

flush_deferred_drops silently skips remaining ops if one panics, and is unsafe to call from within an active deferred context.

Two concerns:

  1. Incomplete flush on panic: If op() at index i panics, the remaining ops in the local ops vec are dropped (not called). Any untrack_object() calls they contained will be silently lost, leaving the GC tracking table in an inconsistent state for the duration of the thread's life.

  2. Callable within a deferred context: Since flush_deferred_drops is pub, a caller can invoke it while IN_DEFERRED_CONTEXT is true, prematurely draining the queue and defeating the deferral guarantee. Consider a debug_assert guard.

🛡️ Suggested hardening
 pub fn flush_deferred_drops() {
+    debug_assert!(
+        !IN_DEFERRED_CONTEXT.with(|c| c.get()),
+        "flush_deferred_drops called from within an active deferred context"
+    );
     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();
         }
     });
 }

For the panic-completeness concern, consider iterating with a RAII sentinel that re-pushes un-executed ops back to the queue on drop, though this adds complexity that may not be warranted for a GC untrack path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/common/src/refcount.rs` around lines 262 - 270, flush_deferred_drops
currently drains DEFERRED_QUEUE and calls each op, but if any op panics the rest
are lost and callers can invoke it while IN_DEFERRED_CONTEXT is true; update
flush_deferred_drops to first assert (or debug_assert) that IN_DEFERRED_CONTEXT
is false to prevent reentrancy, then take the ops vec and execute each op inside
std::panic::catch_unwind so a panic from one op does not skip others, and if a
panic occurs re-push any remaining ops back into DEFERRED_QUEUE (using
DEFERRED_QUEUE.with) before resuming the panic unwind; reference
flush_deferred_drops, DEFERRED_QUEUE, IN_DEFERRED_CONTEXT and ensure any
untrack_object semantics are preserved by requeuing unexecuted closures.

}
Loading