fix(gc): pinned objects are marked, traced and scanned as roots (use-after-free in cross-thread promises) - #11664
Conversation
Every mark entry treated GC_FLAG_PINNED as already marked, so a pinned object was never traced and a child reachable only through it was freed: js_promise_new_cross_thread lost its reaction closure after one full. A pin now means only no move, no sweep. Pinned objects are rooted by a scanner that finds them through the header bit plus a per-block pinned_summary (arena) and a malloc-registry summary, set only by the pin setters. Block persistence no longer counts a pinned header as live.
…ix complete A pinned gc_malloc parent lost its young child under a copying minor and a forced evacuation: the copying minor marked malloc and long-lived objects only when neither MARKED nor PINNED was set, so the pinned parent reached through the pin scanner was never scanned. A malloc parent is never remembered by the write barrier, so that scan was its only cover. The same PINNED-as-marked early-out is removed from the incremental mark barrier and the budgeted cycle block persistence. The born-tenured and old controls that failed under a minor were a test bug: the copying-nursery isolation guard empties the scanner registry, and without the shape table scanner an old parent keeps its forwarded slot but loses the young keys array that names it. The pinned-root tests now register it, cover every birth under full, minor and forced evacuation, assert a pinned parent is never moved, and check the full trace exactly by running its root scan and mark worklist (sabotage of either arm turns it red).
…linear block iteration
…on-young pin stays arena-free The pinned-summary block walk no longer casts headers itself: the linear block iteration the arena walkers share is one function in arena/walk.rs (for_each_block_header), used by the filtered and block-index walkers and by the pinned walk. The addr_class allowlist entry for arena/pinned.rs is gone. pin_object_non_young must stay as light as #7655 made it (#7650: it is kept by the feature-stripped perry-ext-* links). It no longer reaches note_pinned_arena_header: a non-leaf tenured arena pin made through it sets a leaf thread-local (TENURED_PIN_UNPLACED), and the next full root scan walks every tenured block once, which places the pin in its block summary. A minor leaves the bit set; it does not act on tenured objects. Test: full_mark_traces_through_every_non_young_pin (born-tenured, old, malloc parents pinned through pin_user_ptr_non_young); red with the placement sabotaged.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (17)
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughThe runtime now discovers non-leaf pinned objects as roots and traces their children during garbage collection. Arena summaries and malloc-registry summaries support pinned-object discovery. Marking paths and block-persistence scans no longer treat pinned objects as marked by default. ChangesPinned GC roots
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant PinSetter
participant ArenaSummary
participant GCInitializer
participant PinnedRootScanner
participant MarkingVisitor
participant ChildObject
PinSetter->>ArenaSummary: record pinned header
GCInitializer->>PinnedRootScanner: register root scanner
PinnedRootScanner->>ArenaSummary: collect pinned headers
PinnedRootScanner->>MarkingVisitor: visit pinned object
MarkingVisitor->>ChildObject: trace child reference
Suggested reviewers: Merge Risk: ⚪ Minimal · up to The inspected cross-thread promise paths retain their GC roots. No actionable issue is established that would prevent merging after normal checks. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to The change addresses a use-after-free affecting objects reachable through pinned promises. The ordinary collection path is supported by the reviewed code and tests, but it remains unclear whether a pin made late in an incremental collection is traced before its children can be reclaimed. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 61.02% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 59 functions across 15 files. (2 skipped: 2 unsupported.)
✨ 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 |
Fixes a use-after-free: the marker never marked or traced pinned objects, so anything reachable only through a pinned object got freed.
The bug
Every mark entry treated
GC_FLAG_PINNEDas "already marked" and returned without tracing. That coveredtry_mark_value,try_mark_raw_root_addr,mark_field_into_worklist,try_mark_young_user_ptr_as_seed, the root scanners, the copying minor's malloc/long-lived mark, the incremental mark barrier, and the budgeted cycle's block persistence.js_promise_new_cross_thread(promise/then.rs: gc_malloc + pin) loses its then/await reaction closure after one full collection, and the next allocation reuses the memory. Callers: bcrypt, sharp, container compose_ffi, the worker_threads shim, turnloop_client, thread spawn.async_bridgepromise (fetch/zlib/ws) survived only because the full trace force-marked recent blocks that held a pinned header.The fix
pinned_summarybit, set only bypin_object/pin_object_non_young(the gc_pin_sites gate enforces this).arena/walk.rs::for_each_block_header). The address-classification gate passes on code alone, with no allowlist entry.Tests
gc/tests/pinned_roots.rshas 18 tests. They cover 4 parent births × {full, copying minor, forced evacuation}, pinned and unpinned, plus the cross-thread promisethencallback and the aged async_bridge promise across 4 full collections, and a full-mark trace-through test for every non-young pin. Sabotaging either half of the fix turns them red.Verification (Linux x86_64, vs main)
--checkidentical; gc_pin_sites, root_holders, address classification, file size and fmt passSummary by CodeRabbit