Give the strong count the bits an unused weak field held (#8574)

* Give the strong count the bits an unused weak field held

RefCount packs its state into one usize and splits the non-flag bits evenly
between a strong and a weak count, so a 32-bit target left the strong count
15 bits. An object reaching 32 767 references then called refcount_overflow,
which aborts the process; on wasm that surfaces as an unreachable trap with
no Python-level error. Two ordinary module imports pass that total.

The weak half is never read and never written. WEAK_COUNT appears once
outside its own definition, in RefCount::new, and nothing in the repository
observes those bits: weak references live in the object's WeakRefList, which
Py::weak_count walks. The strong count now takes every bit the three flags
leave, which is 29 on a 32-bit target and 61 on a 64-bit one.

The tests run on wasm32-wasip2, where the old layout traps and the new one
passes. A 64-bit host passes either way, since 31 bits already covered them.

Fixes #8469

Assisted-by: Claude Code:claude-opus-5

* Return the strong count as usize so a 64-bit target cannot truncate it

The count is 61 bits wide on a 64-bit target once it takes the bits the weak
field held, so a u32 return truncates above 4 294 967 295 references: inc
would read a wrapped 0 and re-increment, and dec would read a wrapped 1 and
free a live object. A 32-bit target is unaffected, where 29 bits fit u32.

Assisted-by: Claude Code:claude-opus-5

* Cover the refcount ceiling through inc and dec

inc_by writes its own overflow check, so a test built on it leaves inc and
dec unexercised. The reported aborts came through inc, one reference at a
time.

Assisted-by: Claude Code:claude-opus-5

---------

Co-authored-by: Jean-Marc Le Roux <jeanmarc@lx.industries>
This commit is contained in:
Jeong, YunWonandJean-Marc Le Roux authored and GitHub committed 2026-08-22 16:42:12 +09:00
1 parent ed62d4a62b
commit 4ea9e84a3a
1 file changed
+68 -16
+68 -16
View File
@@ -1,8 +1,13 @@
use crate::atomic::{Ordering, PyAtomic, Radium};
// State layout (usize):
// [1 bit: destructed] [1 bit: published] [1 bit: leaked] [N bits: weak_count] [M bits: strong_count]
// 64-bit: N=30, M=31. 32-bit: N=14, M=15.
// [1 bit: destructed] [1 bit: published] [1 bit: leaked] [M bits: strong_count]
// 64-bit: M=61. 32-bit: M=29.
//
// Weak references live in the object's `WeakRefList`, not in this word, so the
// strong count takes every bit the flags leave. A 32-bit target reaches its
// ceiling at 536 870 911 references rather than the 32 767 that half the word
// would allow — a number two ordinary module imports pass on `wasm32`.
const FLAG_BITS: u32 = 3;
const DESTRUCTED: usize = 1 << (usize::BITS - 1);
/// Object was published to a lock-free cache; memory reclamation is
@@ -10,12 +15,9 @@ const DESTRUCTED: usize = 1 << (usize::BITS - 1);
/// freed memory. Sticky once set.
const PUBLISHED: usize = 1 << (usize::BITS - 2);
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_WIDTH: u32 = usize::BITS - FLAG_BITS;
const STRONG: usize = (1 << STRONG_WIDTH) - 1;
const COUNT: usize = 1;
const WEAK_COUNT: usize = 1 << STRONG_WIDTH;
#[inline(never)]
#[cold]
@@ -48,8 +50,8 @@ impl State {
}
#[inline]
fn strong(self) -> u32 {
((self.inner & STRONG) / COUNT) as u32
fn strong(self) -> usize {
(self.inner & STRONG) / COUNT
}
#[inline]
@@ -76,8 +78,8 @@ impl State {
/// Reference count using state layout with LEAKED support.
///
/// State layout (usize):
/// 64-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [30 bits: weak_count] [31 bits: strong_count]
/// 32-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [14 bits: weak_count] [15 bits: strong_count]
/// 64-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [61 bits: strong_count]
/// 32-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [29 bits: strong_count]
pub struct RefCount {
state: PyAtomic<usize>,
}
@@ -92,23 +94,22 @@ impl RefCount {
/// Create a new RefCount with strong count = 1
#[must_use]
pub fn new() -> Self {
// Initial state: strong=1, weak=1 (implicit weak for strong refs)
Self {
state: Radium::new(COUNT + WEAK_COUNT),
state: Radium::new(COUNT),
}
}
/// Get current strong count
#[inline]
pub fn get(&self) -> usize {
State::from_raw(self.state.load(Ordering::Relaxed)).strong() as usize
State::from_raw(self.state.load(Ordering::Relaxed)).strong()
}
/// Increment strong count
#[inline]
pub fn inc(&self) {
let val = State::from_raw(self.state.fetch_add(COUNT, Ordering::Relaxed));
if val.destructed() || (val.strong() as usize) > STRONG - 1 {
if val.destructed() || val.strong() > STRONG - 1 {
refcount_overflow();
}
if val.strong() == 0 {
@@ -121,7 +122,7 @@ impl RefCount {
pub fn inc_by(&self, n: usize) {
debug_assert!(n <= STRONG);
let val = State::from_raw(self.state.fetch_add(n * COUNT, Ordering::Relaxed));
if val.destructed() || (val.strong() as usize) > STRONG - n {
if val.destructed() || val.strong() > STRONG - n {
refcount_overflow();
}
}
@@ -135,7 +136,7 @@ impl RefCount {
if old.destructed() || old.strong() == 0 {
return false;
}
if (old.strong() as usize) >= STRONG {
if old.strong() >= STRONG {
refcount_overflow();
}
let new_state = old.add_strong(1);
@@ -299,6 +300,57 @@ pub fn flush_deferred_drops() {
mod tests {
use super::*;
/// The strong count reaches far past a 16-bit ceiling on every target.
///
/// The count shares its word with the flag bits, so its width follows the
/// pointer width. A 32-bit target is the one this guards: a second counter
/// packed beside the strong count once left it 15 bits, and `wasm32`
/// aborted at 32 767 references — a total two ordinary module imports
/// pass. The check is a no-op on a 64-bit host, where 31 bits already
/// covered this; run the crate's tests against a 32-bit target to exercise
/// it.
#[test]
fn strong_count_reaches_past_a_16_bit_ceiling() {
const REFERENCES: usize = 1 << 20;
let rc = RefCount::new();
rc.inc_by(REFERENCES);
assert_eq!(rc.get(), REFERENCES + 1);
}
/// `inc` and `dec` reach the same ceiling as `inc_by`.
///
/// The aborts reported against this layout came one reference at a time
/// through `inc`, whose overflow check is written separately from
/// `inc_by`'s, and the count has to come back down through `dec` without
/// reporting the object collectable before the last reference goes.
#[test]
fn inc_and_dec_reach_past_a_16_bit_ceiling() {
const REFERENCES: usize = 1 << 20;
let rc = RefCount::new(); // strong = 1
for _ in 1..REFERENCES {
rc.inc();
}
assert_eq!(rc.get(), REFERENCES);
for _ in 1..REFERENCES {
assert!(!rc.dec());
}
assert_eq!(rc.get(), 1);
assert!(rc.dec());
}
/// A fresh count holds exactly one strong reference and no stray bits.
///
/// `get` masks the flags away, so a spare field left in the word would not
/// show up there. Reading the raw state keeps the layout honest.
#[test]
fn a_new_refcount_holds_one_strong_reference_and_nothing_else() {
let rc = RefCount::new();
assert_eq!(rc.get(), 1);
assert_eq!(rc.state.load(Ordering::Relaxed), COUNT);
}
#[test]
fn published_bit_survives_refcount_traffic() {
let rc = RefCount::new(); // strong = 1