Two findings, one blocked conversion
While clearing the raw thread_local! blocks that keep tls-budget red on main (four converted in the linked PR), gc/census.rs could not be converted honestly. It surfaced both a gate blind spot and a gap in the verdict vocabulary.
1. A raw thread_local! is invisible to gc_runtime_root_holders.py
The holder inventory enumerates perry_thread_local! declarations (rule T). A raw thread_local! is therefore audited by neither gate: check_thread_locals.py flags it as a TLS-perf violation, but nothing asks whether it holds a heap pointer.
That is the wrong way round — the declarations that skipped the convention are exactly the ones least likely to have had their GC contract considered. Converting four files immediately exposed six previously-unclassified holders, five trivial and one not.
Suggestion: have the holder inventory enumerate raw thread_local! blocks too, so skipping the convention cannot also skip classification.
2. PASS1_MARKED holds heap addresses that no verdict describes truthfully
/// Pass-1 snapshot: sorted header addresses that were marked when mark
/// propagation finished (see the module docs).
static PASS1_MARKED: RefCell<Option<Vec<usize>>> = const { RefCell::new(None) };
Those usizes are real GC header addresses. Against the vocabulary:
not_a_gc_pointer — defined as "an id, a counter, an epoch, a code address, a .rodata object, or Rust-owned state". A heap address is none of these. Using it would be false.
covered_elsewhere — requires a named registered scanner. None visits this, and none should: tracing the marked set would retain exactly the garbage the census exists to observe.
open_gap — asserts a real unrooted GC pointer, and fails the gate. It is not a gap.
unverified — fails the gate, and the contract is verified (below).
test_only — it is env-gated diagnostic code, not #[cfg(test)].
The actual contract, from the module's own docs:
Inside a SYNCHRONOUS full cycle the mark bits are authoritative and nothing moves.
The addresses are written in census_pass1_if_armed at the end of mark propagation and take()n at sweep entry in the same synchronous full cycle, with no relocation and no mutator resumption in that window. They are safe by construction, and deliberately untraced. Off by default — ARMED is false unless PERRY_GC_CENSUS=<path> is set, leaving the cell None.
So the holder is fine and the vocabulary cannot say so. Rather than force a wrong label to make a gate green, census.rs is left as a raw thread_local! and tls-budget stays red on that one file.
Suggestion: add a verdict for "holds addresses, deliberately untraced, valid only within a single non-moving window", carrying the window's boundaries so a future change that widens it fails the gate. That is a real contract worth pinning — it is the same shape as the retained-array-growth-stub class in #9717.
Happy to implement whichever direction the maintainers prefer.
Two findings, one blocked conversion
While clearing the raw
thread_local!blocks that keeptls-budgetred onmain(four converted in the linked PR),gc/census.rscould not be converted honestly. It surfaced both a gate blind spot and a gap in the verdict vocabulary.1. A raw
thread_local!is invisible togc_runtime_root_holders.pyThe holder inventory enumerates
perry_thread_local!declarations (rule T). A rawthread_local!is therefore audited by neither gate:check_thread_locals.pyflags it as a TLS-perf violation, but nothing asks whether it holds a heap pointer.That is the wrong way round — the declarations that skipped the convention are exactly the ones least likely to have had their GC contract considered. Converting four files immediately exposed six previously-unclassified holders, five trivial and one not.
Suggestion: have the holder inventory enumerate raw
thread_local!blocks too, so skipping the convention cannot also skip classification.2.
PASS1_MARKEDholds heap addresses that no verdict describes truthfullyThose
usizes are real GC header addresses. Against the vocabulary:not_a_gc_pointer— defined as "an id, a counter, an epoch, a code address, a .rodata object, or Rust-owned state". A heap address is none of these. Using it would be false.covered_elsewhere— requires a named registered scanner. None visits this, and none should: tracing the marked set would retain exactly the garbage the census exists to observe.open_gap— asserts a real unrooted GC pointer, and fails the gate. It is not a gap.unverified— fails the gate, and the contract is verified (below).test_only— it is env-gated diagnostic code, not#[cfg(test)].The actual contract, from the module's own docs:
The addresses are written in
census_pass1_if_armedat the end of mark propagation andtake()n at sweep entry in the same synchronous full cycle, with no relocation and no mutator resumption in that window. They are safe by construction, and deliberately untraced. Off by default —ARMEDis false unlessPERRY_GC_CENSUS=<path>is set, leaving the cellNone.So the holder is fine and the vocabulary cannot say so. Rather than force a wrong label to make a gate green,
census.rsis left as a rawthread_local!andtls-budgetstays red on that one file.Suggestion: add a verdict for "holds addresses, deliberately untraced, valid only within a single non-moving window", carrying the window's boundaries so a future change that widens it fails the gate. That is a real contract worth pinning — it is the same shape as the retained-array-growth-stub class in #9717.
Happy to implement whichever direction the maintainers prefer.