Skip to content

fix(gc): prune DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY on buffer death (#6337)#6341

Merged
proggeramlug merged 3 commits into
mainfrom
fix/6337-dataview-sab-registry-prune
Jul 13, 2026
Merged

fix(gc): prune DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY on buffer death (#6337)#6341
proggeramlug merged 3 commits into
mainfrom
fix/6337-dataview-sab-registry-prune

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #6337.


⚠️ MERGE ORDER: land #6310 FIRST

This PR edits the same function as open PR #6310 (issue #6302, KeyObject.from()): finalize_collected_dead_buffer in crates/perry-runtime/src/buffer/header.rs. #6310 adds the WebCrypto/KeyObject tables to it; this PR adds the buffer-identity tables. Please merge #6310 first, then this one.

Verified: the two do NOT textually conflict. I deliberately anchored my prune block after ARRAY_BUFFER_REGISTRY, rather than after BUFFER_AB_ALIAS where #6310 inserts, specifically to keep the hunks apart. git merge-tree against #6310's head (573ad38ce) auto-merges clean, and the merged finalize_collected_dead_buffer contains both sets of prunes:

BUFFER_REGISTRY            (pre-existing)
ARRAY_BUFFER_REGISTRY      (pre-existing)
SHARED_ARRAY_BUFFER_REGISTRY  ← this PR
DATA_VIEW_REGISTRY            ← this PR
BUFFER_AB_ALIAS            (pre-existing)
CRYPTO_KEY_META_REGISTRY   ← #6310
SECRET_KEY_REGISTRY        ← #6310
UINT8ARRAY_FROM_CTOR       ← #6310
external_{buffers,uint8arrays,crypto_keys}  ← #6310
notify_crypto_key_death()  ← #6310
super::detach / super::view (pre-existing)

So no rebase should be needed either way — but if anything does drift, this branch is trivially rebasable onto #6310. This PR does not duplicate any of #6310's work.


The bug

finalize_collected_dead_buffer exists to drop "every registry/side-table entry keyed by a dead buffer's address" — its own doc comment cites preventing the #6080 ABA class. But it only pruned BUFFER_REGISTRY, ARRAY_BUFFER_REGISTRY and BUFFER_AB_ALIAS.

DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY had zero .remove/.retain sites anywhere in the tree. Same pair of consequences already proven for the WebCrypto tables in #6302/#6310:

  1. Unbounded leak — one permanent entry per DataView / SAB-flagged buffer ever created, for the life of the process.
  2. ABA / address reusearena_reset_empty_blocks resets a fully-empty block's offset to 0 while keeping its base pointer, so a reset block re-issues the same addresses. A stale entry then answers for an unrelated object. is_data_view / is_shared_array_buffer gate util.types.isDataView / isSharedArrayBuffer, ArrayBuffer.isView, the [object DataView] tag, and the structuredClone / .slice() re-marking paths — a fresh Buffer landing on a recycled address would answer to all of them.

Both are now pruned in the post-full-trace sweep, alongside the existing three. (Buffers are TENURED old-gen residents; minor traces never mark the old generation, so only a full trace can prove them dead. Pruning rides the same CollectionSideBuffers subphase #6310 uses.)

The SharedArrayBuffer wrinkle — a second, separate bug

Investigating the SAB side surfaced a soundness bug that is not just hygiene, and the fix is the opposite of pruning.

A process-global SAB backing (shared_sab::alloc_shared_sab) is not a GC allocation: it comes straight from alloc_zeroed, carries no GcHeader, and is never freed — that is exactly what lets its bytes alias across perry/thread agents (#4913). But js_shared_array_buffer_new does register_buffer it, so it lands in BUFFER_REGISTRY and reaches registered_buffer_is_dead_post_trace on every full trace.

There, try_read_gc_header reads the 8 bytes before the malloc block — the allocator's own metadata — and interprets them as a GcHeader. obj_type and gc_flags are both u8, so this is one arbitrary byte compared against GC_TYPE_BUFFER (10) and the next against the mark/pin/forward bits. A chance match declares a live, never-freed SAB dead. finalize_collected_dead_buffer then runs on it — including view::remove_entries_for_dead_buffer, which does retain(|_, info| info.backing != addr) and so unregisters every live typed-array view over that SAB: precisely the views cross-agent Atomics.wait/notify resolve their absolute slot addresses through.

So the backing is now vetoed as a dead candidate before any header is sniffed, rather than pruned. This also removes an out-of-bounds read on a raw malloc block.

Nothing about cross-thread aliasing changes. The process-global registry (shared_sab), the shared backing store, and the atomics_futex table are untouched. The entries that do get pruned from SHARED_ARRAY_BUFFER_REGISTRY are the arena-allocated SAB-flagged copiesSharedArrayBuffer.prototype.slice (object/buffer_dispatch.rs), structuredClone (builtins/globals.rs) — which are ordinary GC-heap buffers that genuinely die and whose addresses genuinely get recycled. Those are the only SAB-registry entries that were ever collectable, and they were the leak.

Per the issue's warning, the fix does not pin anything: GC_FLAG_PINNED objects are not traced (gc/cycle.rs only enqueues when flags & (MARKED|PINNED) == 0), so pinning would trade a stale entry for an untraced-reference walk and make the leak permanent by design.

Incidental: the veto is free

The veto sits behind a SHARED_SAB_NONEMPTY latch mirroring the existing EXTERNAL_BUFFERS_NONEMPTY idiom, so processes that never allocate a SAB — nearly all of them — answer from one relaxed atomic load and never take the registry lock. That also removes an unconditional mutex acquisition from is_registered_buffer's final fallback, which JSON.stringify runs per serialized pointer (#6009) — the existing EXTERNAL_BUFFERS_NONEMPTY latch was being partly defeated by the is_shared_sab call right after it.

Leak numbers

New test test_data_view_and_sab_registries_drain_after_owners_die: allocate 4096 DataViews + 4096 SAB-flagged buffers, drop every reference, run one full collection.

before fix after fix
DATA_VIEW_REGISTRY 4096 → 4096 (0 pruned) 4096 → 0
SHARED_ARRAY_BUFFER_REGISTRY 4096 → 4096 (0 pruned) 4096 → 0

Tests

New crates/perry-runtime/src/gc/tests/buffer_side_tables.rs (6 tests):

  • test_dead_data_view_registry_entry_pruned_on_full_gc
  • test_dead_shared_array_buffer_flag_pruned_on_full_gc
  • test_data_view_and_sab_registries_drain_after_owners_die — the leak regression above
  • test_live_data_view_and_shared_array_buffer_flags_survive_full_gc — safety inverse; live (rooted) views keep their entries
  • test_seeded_shared_sab_is_never_a_dead_buffer_candidate — proves the SAB veto deterministically. The malloc-metadata coincidence cannot be forced without writing outside the allocation, so this seeds an ordinary GC buffer (whose real header genuinely says "dead") into the shared-SAB registry and asserts the dead-buffer scan still refuses to report it.
  • test_process_global_sab_backing_survives_full_gc_unrooted — end-to-end over a real new SharedArrayBuffer(64).

All four bug-specific tests fail without the fix (verified by reverting the prune + veto: 4 FAILED, and the two safety-inverse tests still pass — so they aren't vacuous in either direction).

Validation

  • New suite: 6/6 pass, and 6/6 under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1. PERRY_GC_DIAG=1 confirms collections actually ran (freed_bytes=262144 / 48, and longlived=4 (1 live) on the rooted case) rather than being silently vacuous.
  • Full perry-runtime lib suite: 1291 passed, 0 failed.
  • Gap suite: no new untriaged failures.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean.

Ralph Küpper added 3 commits July 13, 2026 07:29
… buffer death (#6337)

`finalize_collected_dead_buffer` exists to drop "every registry/side-table
entry keyed by a dead buffer's address" — its doc comment cites preventing the
#6080 ABA class — but it only pruned BUFFER_REGISTRY, ARRAY_BUFFER_REGISTRY and
BUFFER_AB_ALIAS. `DATA_VIEW_REGISTRY` and `SHARED_ARRAY_BUFFER_REGISTRY` had
zero `.remove`/`.retain` sites anywhere in the tree.

Two consequences, the same pair proven for the WebCrypto tables in #6302/#6310:

  * unbounded leak — one permanent entry per DataView / SAB-flagged buffer ever
    created, for the life of the process;
  * ABA / address reuse — `arena_reset_empty_blocks` resets a fully-empty
    block's offset to 0 while keeping its base pointer, so a reset block
    re-issues the same addresses and a stale entry answers for an unrelated
    object. `is_data_view`/`is_shared_array_buffer` gate `util.types.isDataView`
    / `isSharedArrayBuffer`, `ArrayBuffer.isView`, the `[object DataView]` tag,
    and the structuredClone / `.slice()` re-marking paths.

Both are now pruned in the post-full-trace sweep, alongside the existing three.

SharedArrayBuffer needed a second, separate fix. A process-global SAB backing
(`shared_sab::alloc_shared_sab`) is NOT a GC allocation: it comes straight from
`alloc_zeroed`, carries no GcHeader, and is never freed — that is what lets the
bytes alias across `perry/thread` agents (#4913). But `js_shared_array_buffer_new`
does `register_buffer` it, so it reaches `registered_buffer_is_dead_post_trace`
on every full trace, where `try_read_gc_header` reads the 8 bytes BEFORE the
malloc block and interprets the allocator's own metadata as a GcHeader: one
arbitrary byte compared against GC_TYPE_BUFFER (10), the next against the
mark/pin/forward bits. A chance match declares a LIVE, never-freed SAB dead —
and `finalize_collected_dead_buffer` then runs `view::remove_entries_for_dead_buffer`,
which retains on `info.backing != addr` and so unregisters EVERY live
typed-array view over that SAB: exactly the views cross-agent `Atomics`
wait/notify resolve their absolute slot addresses through.

So the backing is now vetoed as a dead candidate before any header is sniffed,
rather than pruned. The entries that DO get pruned from
SHARED_ARRAY_BUFFER_REGISTRY are the arena-allocated SAB-flagged copies
(`SharedArrayBuffer.prototype.slice`, structuredClone), which are ordinary
GC-heap buffers that genuinely die and whose addresses genuinely get recycled.
Cross-thread aliasing, the process-global registry and the Atomics futex table
are untouched.

The veto sits behind a `SHARED_SAB_NONEMPTY` latch mirroring
`EXTERNAL_BUFFERS_NONEMPTY`, so processes that never allocate a SAB — nearly all
of them — answer from one relaxed atomic load and never take the registry lock.
That also removes an unconditional mutex acquisition from `is_registered_buffer`'s
fallback, which JSON.stringify runs per serialized pointer (#6009).

Tests (`gc/tests/buffer_side_tables.rs`): dead DataView / SAB-flagged buffer
pruned on full GC; live (rooted) ones survive; a 4096-entry leak regression that
asserts both registries DRAIN to baseline after their owners are swept; and two
SAB-liveness tests, one seeding the shared-SAB registry to prove the veto
deterministically (the malloc-metadata coincidence cannot be forced without
writing outside the allocation), one end-to-end over a real
`new SharedArrayBuffer(64)`.
GcTestIsolationGuard does not push a shadow frame, so js_shadow_slot_set
under it roots nothing — the buffers stayed unreachable and the safety
inverse would have passed for the wrong reason. Use CopyingNurseryTestGuard,
which pushes the frame (same as the dead_owner_side_tables precedent).
The veto in registered_buffer_is_dead_post_trace called is_shared_sab per
registered buffer, taking the process-global registry mutex once per buffer
per full trace in any program that allocates a SharedArrayBuffer. The set is
tiny and the GC is stop-the-world here, so lock it once per scan and hand the
filter a plain set. Returns None — and skips the check entirely, allocating
nothing — for the processes that never allocate a SAB.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds process-global SharedArrayBuffer registry snapshots, prevents SAB-backed buffers from being misclassified during GC scanning, and prunes DataView and SharedArrayBuffer identity registries during dead-buffer finalization. New tests cover cleanup, retention, registry draining, and process-global SAB survival.

Changes

Buffer GC cleanup

Layer / File(s) Summary
SharedArrayBuffer tracking and snapshots
crates/perry-runtime/src/shared_sab.rs
Adds an atomic nonempty latch, synchronized registry updates and lookups, GC snapshots, and test-only seed helpers.
Dead-buffer scanning and registry finalization
crates/perry-runtime/src/buffer/header.rs, crates/perry-runtime/src/buffer/mod.rs
Passes a SharedArrayBuffer snapshot through dead-buffer scanning, vetoes SAB-backed candidates, prunes DataView and SharedArrayBuffer entries, and re-exports registry length helpers for tests.
Buffer side-table regression tests
crates/perry-runtime/src/gc/tests/buffer_side_tables.rs, crates/perry-runtime/src/gc/tests/mod.rs
Adds full-GC tests for registry pruning, rooted identity retention, registry draining, seeded SAB protection, and process-global SAB survival.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • PerryTS/perry#6190: Updates the same GC dead-buffer handling and buffer identity cleanup paths.
  • PerryTS/perry#6273: Modifies dead-buffer finalization and per-buffer identity cleanup in the same runtime area.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The diff prunes both registries on full GC, adds regression tests, and avoids pinning, matching #6337's requirements.
Out of Scope Changes check ✅ Passed The shared_sab changes support the SAB veto and new tests, so they stay tied to the PR's objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title accurately and concisely summarizes the main change: pruning the two registries on buffer death.
Description check ✅ Passed The description covers the issue, changes, tests, and validation details, though it does not follow the template headings exactly.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6337-dataview-sab-registry-prune

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/shared_sab.rs`:
- Around line 90-98: Update is_shared_sab and snapshot_shared_sabs to recover
poisoned registry mutexes using the poisoned lock’s into_inner() guard, matching
alloc_shared_sab. Remove the unwrap_or(false) and .ok() paths so GC checks
continue using the registry after lock poisoning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 92eaab2b-0faf-4710-b998-bab758f9832f

📥 Commits

Reviewing files that changed from the base of the PR and between 735e894 and 2d69880.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/mod.rs
  • crates/perry-runtime/src/gc/tests/buffer_side_tables.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/shared_sab.rs

Comment on lines 90 to 98
pub fn is_shared_sab(addr: usize) -> bool {
if !SHARED_SAB_NONEMPTY.load(Ordering::Acquire) {
return false;
}
registry()
.lock()
.map(|r| r.contains(&addr))
.unwrap_or(false)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,220p' crates/perry-runtime/src/shared_sab.rs | cat -n

Repository: PerryTS/perry

Length of output: 7738


🏁 Script executed:

rg -n "snapshot_shared_sabs|is_shared_sab" crates/perry-runtime crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 2251


🏁 Script executed:

sed -n '430,520p' crates/perry-runtime/src/buffer/header.rs | cat -n

Repository: PerryTS/perry

Length of output: 5072


Recover poisoned SAB registry locks
is_shared_sab() and snapshot_shared_sabs() still treat a poisoned mutex as “no SABs exist” (unwrap_or(false) / .ok()), while alloc_shared_sab() already recovers with into_inner(). If a panic ever poisons this lock, the GC scan can stop vetoing live SAB backings and misclassify them as dead. Mirror the same recovery here instead of swallowing the poison.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/shared_sab.rs` around lines 90 - 98, Update
is_shared_sab and snapshot_shared_sabs to recover poisoned registry mutexes
using the poisoned lock’s into_inner() guard, matching alloc_shared_sab. Remove
the unwrap_or(false) and .ok() paths so GC checks continue using the registry
after lock poisoning.

@proggeramlug proggeramlug merged commit dce5fe1 into main Jul 13, 2026
46 of 48 checks passed
@proggeramlug proggeramlug deleted the fix/6337-dataview-sab-registry-prune branch July 13, 2026 13:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gc: DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY are never pruned on buffer death (leak + ABA address-reuse, same class as #6302)

1 participant