Skip to content

fix(gc): prune the buffer own-property table when its buffer dies - #8136

Merged
proggeramlug merged 2 commits into
mainfrom
fix/8117-buffer-own-props-death-prune
Aug 15, 2026
Merged

fix(gc): prune the buffer own-property table when its buffer dies#8136
proggeramlug merged 2 commits into
mainfrom
fix/8117-buffer-own-props-death-prune

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

finalize_collected_dead_buffer (crates/perry-runtime/src/buffer/header.rs) exists to "drop every registry/side-table entry keyed by a dead buffer's address" — its own docstring — and it prunes eleven of them: BUFFER_REGISTRY, ARRAY_BUFFER_REGISTRY, SHARED_ARRAY_BUFFER_REGISTRY, DATA_VIEW_REGISTRY, BUFFER_AB_ALIAS, CRYPTO_KEY_META_REGISTRY, SECRET_KEY_REGISTRY, UINT8ARRAY_FROM_CTOR, the three external_* maps, plus the detach and view registries.

The own-property table (buf.foo = v, #6406) was not one of them. Its only clear site is register_buffer, which fires only when the recycled address is re-issued to another buffer — so an entry whose address is never reused, or is reused by a plain object, survived for the life of the process.

Two consequences, and the second is why this table matters more than the identity registries beside it:

  • An unbounded leak — one permanent entry per property-carrying Buffer/DataView ever created.
  • scan_buffer_own_props_roots_mut traces the stored VALUES in every GC phase. So a dead buffer's expando closure — and everything it captures — stayed reachable forever. And its dead owner key kept being handed to visit_metadata_usize_slot, which re-resolves it against whatever now occupies those bytes: the Read-PIC keyed on raw keys-array address: ABA staleness + defineProperty-after-prime bypass #6080 ABA class this function exists to prevent, in the one table that was not covered.

The fix is one line plus the doc comments that explain why the table needs two clear sites rather than one.

Testing

Three tests, in crates/perry-runtime/src/gc/tests/buffer_side_tables.rs — the file that already exists to cover exactly this function's coverage gaps (#6337 added it for DATA_VIEW_REGISTRY and SHARED_ARRAY_BUFFER_REGISTRY, which had the identical "no .remove site anywhere in the tree" shape).

Each was watched fail with the one-line prune reverted:

---- test_dead_buffer_own_property_entry_pruned_on_full_gc ----
assertion `left == right` failed: a dead buffer's own-property entry must be pruned …
  left: Some(7.0)
 right: None

---- test_buffer_own_props_table_drains_after_owners_die ----
the own-property table must drain when its owners die: 514 owners remain,
expected at most the pre-test 2
  • test_dead_buffer_own_property_entry_pruned_on_full_gc — the per-address probe.
  • test_buffer_own_props_table_drains_after_owners_die — the leak regression. 512 property-carrying buffers, all references dropped, one full collection; a per-address probe cannot show a monotonically growing table.
  • test_live_buffer_keeps_its_own_properties_across_full_gcthe control. Without it the prune could pass by dropping entries unconditionally. It uses CopyingNurseryTestGuard::new(1) rather than GcTestIsolationGuard, because only that guard pushes the shadow frame that makes js_shadow_slot_set an actual root — the same trap the neighbouring DataView/SAB survival test documents. It stays green under the sabotage, so the two failures above are the prune and not the harness.

Results:

  • cargo test -p perry-runtime --lib2392 passed, 0 failed, 4 ignored (repo baseline 2389 + these three), exit 0. The restore build recompiled perry-runtime exactly once.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean; all sixteen lint-gate scripts run individually, all exit 0.

Scope — this is NOT a fix for #8117

Found while bisecting #8117's two pass -> crash gap regressions (test_gap_buffer_own_props, test_gap_6386_dataview_concat_regex_fastpaths). It is not a fix for them and is not claimed as one:

Landing separately on its own merits. A second divergence in the same file is left for a follow-up and deliberately not folded in here: scan_buffer_own_props_roots_mut claims to mirror closure::dynamic_props "(same locked side table + GC root scanner contract)", but the closure version falls back to forwarded_heap_owner(owner) when the visitor did not rewrite the key and re-inserts through merge_closure_prop_map so a post-rewrite key collision merges instead of clobbering; the buffer version does a bare props.insert(new_owner, entries). With dead owners now pruned, the collision window that divergence opens is much narrower.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed stale buffer properties being retained after garbage collection.
    • Prevented collected buffers from leaving behind data that could interfere with reused memory addresses.
    • Preserved properties for buffers that remain active.
  • Tests

    • Added regression coverage for cleanup after collection, multiple unreachable buffers, and live-buffer preservation.

`finalize_collected_dead_buffer` exists to "drop every registry/side-table
entry keyed by a dead buffer's address" — its own words — and it prunes eleven
such tables. The own-property table (`buf.foo = v`, #6406) was not one of them.

Its only clear site was `register_buffer`, which fires only when the recycled
address is re-issued to another *buffer*. An entry whose address is never
reused, or is reused by a plain object, therefore survived for the life of the
process. Two consequences, and the second is why this table matters more than
the identity registries beside it:

* an unbounded leak — one permanent entry per property-carrying Buffer or
  DataView ever created;
* `scan_buffer_own_props_roots_mut` traces the stored VALUES in every GC phase,
  so a dead buffer's expando closure, and everything it captures, stayed
  reachable forever; and its dead owner key kept being handed to
  `visit_metadata_usize_slot`, which re-resolves it against whatever now
  occupies those bytes — the #6080 ABA class this function exists to prevent.

Three cargo-test-visible tests in the file that already covers this function's
coverage, each watched fail with the one-line prune reverted:

* `test_dead_buffer_own_property_entry_pruned_on_full_gc`
  (left `Some(7.0)`, right `None`);
* `test_buffer_own_props_table_drains_after_owners_die`
  ("514 owners remain, expected at most the pre-test 2") — the leak regression,
  which a per-address probe cannot show;
* `test_live_buffer_keeps_its_own_properties_across_full_gc` — the control that
  keeps the prune from passing by dropping everything. It stays green under the
  sabotage, so the two failures above are the prune and not the harness.

`cargo test -p perry-runtime --lib`: 2392 passed, 0 failed, 4 ignored (baseline
2389 + these three). rustfmt, `scripts/check_file_size.sh` and all sixteen
`lint` gate scripts clean.

Found while bisecting #8117's two `pass -> crash` gap regressions. This is NOT
a fix for those: they are SIGSEGVs that reproduce only on Linux, they bisect to
#7314, and neither has been shown to depend on this table. Landing separately
on its own merits.

Claude-Session: https://claude.ai/code/session_01MsfDzkTEnuS2nh7ygsYkoi
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf6ed9d9-b3ba-4967-97ff-d71f1005f866

📥 Commits

Reviewing files that changed from the base of the PR and between fa83eca and 5b83c1a.

📒 Files selected for processing (5)
  • changelog.d/8136-buffer-own-props-death-prune.md
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/mod.rs
  • crates/perry-runtime/src/buffer/own_props.rs
  • crates/perry-runtime/src/gc/tests/buffer_side_tables.rs

📝 Walkthrough

Walkthrough

The change clears own-property side-table entries during dead-buffer finalization. It adds test-only owner-count access and regression tests for dead buffers, rooted buffers, and multiple unreachable buffers.

Changes

Buffer own-property cleanup

Layer / File(s) Summary
Dead-buffer side-table cleanup
crates/perry-runtime/src/buffer/header.rs, crates/perry-runtime/src/buffer/own_props.rs, crates/perry-runtime/src/buffer/mod.rs
Dead-buffer finalization removes own-property entries. A test-only owner-count helper and re-export support table validation. Cleanup documentation covers registration and finalization.
Cleanup regression coverage
crates/perry-runtime/src/gc/tests/buffer_side_tables.rs, changelog.d/8136-buffer-own-props-death-prune.md
Tests verify removal for dead buffers, preservation for rooted buffers, and table draining after 512 unreachable buffers. The changelog records the fix.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: 🔵 Low · up to 5b83c

The PR fixes cleanup of dead buffer property entries and adds regression coverage; it is mergeable with owner awareness that the runtime test suite should be rerun with a single test thread because the reported parallel run does not fully validate this non-parallel-safe suite.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: pruning the buffer own-property table when its buffer dies.
Description check ✅ Passed The description provides detailed context, concrete changes, regression tests, validation results, and scope limitations, despite omitting the template headings and checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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/8117-buffer-own-props-death-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.

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.

1 participant