Problem
Several helpers are classified CannotCollect in crates/perry-codegen/src/gc_call_effects.rs (for example js_gc_note_slot_layout / _aware ~L140, the js_typed_feedback_* record/observe/guard helpers ~L150–182, and js_closure_set_capture_bits / _ptr ~L235), so lower_roots_for_rs4gc marks their calls "gc-leaf-function" and RS4GC relocates nothing across them.
But some of these take a GcRootRegistryGuard (crates/perry-runtime/src/gc/roots.rs ~L129–185). Its Drop calls exit_gc_root_lock(), and when the lock depth returns to 0, that calls flush_deferred_gc_request() (crates/perry-runtime/src/gc/policy.rs ~L1614). That runs a collection (gc_check_trigger, a direct minor, or a full collection) if a request was deferred while the lock was held. So "cannot collect" holds only if nothing inside the locked region can raise a deferred GC request, i.e. nothing allocates or triggers while holding the lock. That invariant isn't written down or enforced. A future edit adding an allocation inside such a region would silently turn a gc-leaf call into a collecting one, and the caller's unrelocated pointers would go stale.
Fix
Pick one, and document it next to the classification:
- Enforce the invariant: add a debug assertion, active in the
gcaudit profile (note that debug_assert! is compiled out of release and perry-dev), that no deferred GC request is raised while a GcRootRegistryGuard is held from within a CannotCollect helper. Or assert at flush_deferred_gc_request time that the flush didn't come from such a helper.
- Or have
CannotCollect helpers never flush on guard drop: keep the request pending for the next real safepoint, e.g. a guard variant that doesn't flush.
- Reclassify any helper where the flush can genuinely fire.
List exactly which CannotCollect / AllocNoReentry helpers take a GcRootRegistryGuard (or call something that does), with the audit result for each, in the PR.
Verify
- A test that plants an allocation inside the locked region of one such helper (test-only hook) and shows the chosen enforcement catches it (assertion fires, or the request is deferred, not flushed). Sabotage-check it.
- The GC suite (
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib gc::) and cargo test --release -p perry-codegen pass.
Rules for the PR (repo conventions)
- Code + tests + a
changelog.d/<PR>-<slug>.md fragment. No version bump (don't touch [workspace.package].version, the CLAUDE.md version line, or Cargo.lock versions).
- Every new regression test must be sabotage-checked: revert the fix, confirm the test goes red, restore. Say so in the PR body.
perry-runtime tests must run with RUST_TEST_THREADS=1. Build -p perry -p perry-runtime-static -p perry-stdlib-static together (the .a archives come from the -static wrappers).
- Run
scripts/run_lint_gates.sh (or at least cargo fmt --all -- --check, scripts/check_file_size.sh, python3 scripts/raw_handle_debt.py, python3 scripts/addr_class_inventory.py, python3 scripts/gc_runtime_root_holders.py, python3 scripts/gc_rekeyed_key_tables.py).
- Self-contained: no private bundle or special host needed. A normal Linux or macOS dev box is enough.
Filed from the 2026-09 side-table / RSS audit; line numbers are against main e379a7a and may drift.
Problem
Several helpers are classified
CannotCollectincrates/perry-codegen/src/gc_call_effects.rs(for examplejs_gc_note_slot_layout/_aware~L140, thejs_typed_feedback_*record/observe/guard helpers ~L150–182, andjs_closure_set_capture_bits/_ptr~L235), solower_roots_for_rs4gcmarks their calls"gc-leaf-function"and RS4GC relocates nothing across them.But some of these take a
GcRootRegistryGuard(crates/perry-runtime/src/gc/roots.rs~L129–185). ItsDropcallsexit_gc_root_lock(), and when the lock depth returns to 0, that callsflush_deferred_gc_request()(crates/perry-runtime/src/gc/policy.rs~L1614). That runs a collection (gc_check_trigger, a direct minor, or a full collection) if a request was deferred while the lock was held. So "cannot collect" holds only if nothing inside the locked region can raise a deferred GC request, i.e. nothing allocates or triggers while holding the lock. That invariant isn't written down or enforced. A future edit adding an allocation inside such a region would silently turn a gc-leaf call into a collecting one, and the caller's unrelocated pointers would go stale.Fix
Pick one, and document it next to the classification:
gcauditprofile (note thatdebug_assert!is compiled out of release and perry-dev), that no deferred GC request is raised while aGcRootRegistryGuardis held from within aCannotCollecthelper. Or assert atflush_deferred_gc_requesttime that the flush didn't come from such a helper.CannotCollecthelpers never flush on guard drop: keep the request pending for the next real safepoint, e.g. a guard variant that doesn't flush.List exactly which
CannotCollect/AllocNoReentryhelpers take aGcRootRegistryGuard(or call something that does), with the audit result for each, in the PR.Verify
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib gc::) andcargo test --release -p perry-codegenpass.Rules for the PR (repo conventions)
changelog.d/<PR>-<slug>.mdfragment. No version bump (don't touch[workspace.package].version, theCLAUDE.mdversion line, orCargo.lockversions).perry-runtimetests must run withRUST_TEST_THREADS=1. Build-p perry -p perry-runtime-static -p perry-stdlib-statictogether (the.aarchives come from the-staticwrappers).scripts/run_lint_gates.sh(or at leastcargo fmt --all -- --check,scripts/check_file_size.sh,python3 scripts/raw_handle_debt.py,python3 scripts/addr_class_inventory.py,python3 scripts/gc_runtime_root_holders.py,python3 scripts/gc_rekeyed_key_tables.py).Filed from the 2026-09 side-table / RSS audit; line numbers are against
maine379a7a and may drift.