fix(gc): prune the buffer own-property table when its buffer dies - #8136
Conversation
`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
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThe 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. ChangesBuffer own-property cleanup
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: 🔵 Low · up to 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)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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 threeexternal_*maps, plus the detach and view registries.The own-property table (
buf.foo = v, #6406) was not one of them. Its only clear site isregister_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:
Buffer/DataViewever created.scan_buffer_own_props_roots_muttraces 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 tovisit_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 forDATA_VIEW_REGISTRYandSHARED_ARRAY_BUFFER_REGISTRY, which had the identical "no.removesite 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— 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_gc— the control. Without it the prune could pass by dropping entries unconditionally. It usesCopyingNurseryTestGuard::new(1)rather thanGcTestIsolationGuard, because only that guard pushes the shadow frame that makesjs_shadow_slot_setan 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 --lib→ 2392 passed, 0 failed, 4 ignored (repo baseline 2389 + these three), exit 0. The restore build recompiledperry-runtimeexactly once.cargo fmt --all -- --checkclean;scripts/check_file_size.shclean; all sixteenlint-gate scripts run individually, all exit 0.Scope — this is NOT a fix for #8117
Found while bisecting #8117's two
pass -> crashgap 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:PERRY_GC_ZEAL, forced evacuation, from-space protect and scan, andPERRY_GC_SCHEDULE_RATE=1);conformance-smokefails 8/8 shards: 12 real regressions, 10 newly-visible node_fail transitions, 1 fix the snapshot hasn't accepted #8117 (comment);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_mutclaims to mirrorclosure::dynamic_props"(same locked side table + GC root scanner contract)", but the closure version falls back toforwarded_heap_owner(owner)when the visitor did not rewrite the key and re-inserts throughmerge_closure_prop_mapso a post-rewrite key collision merges instead of clobbering; the buffer version does a bareprops.insert(new_owner, entries). With dead owners now pruned, the collision window that divergence opens is much narrower.Summary by CodeRabbit
Bug Fixes
Tests