Skip to content

fix(gc): pinned objects are marked, traced and scanned as roots (use-after-free in cross-thread promises) - #11664

Merged
proggeramlug merged 9 commits into
mainfrom
fix-pinned-objects-traced
Sep 29, 2026
Merged

proggeramlug merged 9 commits into
mainfrom
fix-pinned-objects-traced

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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_PINNED as "already marked" and returned without tracing. That covered try_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.
  • The Eden-pinned async_bridge promise (fetch/zlib/ws) survived only because the full trace force-marked recent blocks that held a pinned header.

The fix

Tests

gc/tests/pinned_roots.rs has 18 tests. They cover 4 parent births × {full, copying minor, forced evacuation}, pinned and unpinned, plus the cross-thread promise then callback 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)

  • runtime 4775/0 (serial); codegen 2326/0
  • gc-root-dominance: 40/40 seeded, stale 0 (unchanged)
  • gc_call_effects --check identical; gc_pin_sites, root_holders, address classification, file size and fmt pass
  • all 18 perry-ext crates build and link
  • parity: gc, promise, worker, bcrypt and thread show the same fail sets as base (worker 8/0, bcrypt 3/0)
  • tsc instructions +0.76%: the pinned-block scan is 0.00% in a profile; the rest comes with 82 vs 80 full collections at a slightly lower live size (a GC-pacing shift, tracked separately). Zod +0.17%.

Summary by CodeRabbit

  • Bug Fixes
    • Pinned objects now remain stationary without being incorrectly treated as already marked during garbage collection. Their reachable children are traced across full, minor, and forced-evacuation collections, preventing those children from being freed prematurely.
    • Pinned objects and their reachable promise reactions are preserved across repeated collections.

Ralph Küpper added 7 commits September 29, 2026 06:58
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).
…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.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 943eac4f-6937-4c05-9b25-dd71d39a42ef

📥 Commits

Reviewing files that changed from the base of the PR and between c968266 and bf0731e.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/gc/mod.rs
  • scripts/gc_runtime_root_holders.json
 ______________________________________________________________________________________________________________________________________
< DRY - Don't Repeat Yourself. Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. >
 --------------------------------------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d7334255-485c-4b8f-8eb4-4b5e853c09c8

📥 Commits

Reviewing files that changed from the base of the PR and between 989c551 and c968266.

📒 Files selected for processing (17)
  • changelog.d/11664-gc-pinned-objects-are-roots.md
  • crates/perry-runtime/src/arena/block.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/pinned.rs
  • crates/perry-runtime/src/arena/promote.rs
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/arena/walk.rs
  • crates/perry-runtime/src/gc/barrier/mod.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/cycle.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/pin.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/pinned_roots.rs
  • crates/perry-runtime/src/gc/trace.rs
  • scripts/gc_runtime_root_holders.json

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The 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.

Changes

Pinned GC roots

Layer / File(s) Summary
Arena summaries and header traversal
crates/perry-runtime/src/arena/block.rs, crates/perry-runtime/src/arena/mod.rs, crates/perry-runtime/src/arena/pinned.rs, crates/perry-runtime/src/arena/promote.rs, crates/perry-runtime/src/arena/quarantine.rs, crates/perry-runtime/src/arena/walk.rs
Arena blocks track whether they may contain pinned headers. Header traversal is shared by arena walkers, and pinned-header collection scans summarized blocks and refreshes their summaries.
Pinned root recording and scanning
crates/perry-runtime/src/gc/pin.rs, crates/perry-runtime/src/gc/mod.rs
Pin setters record non-leaf pinned objects. GC initialization registers a scanner that collects pinned arena and malloc objects and visits them during marking and fix-up passes.
Tracing semantics and survival validation
crates/perry-runtime/src/gc/barrier/mod.rs, crates/perry-runtime/src/gc/copying.rs, crates/perry-runtime/src/gc/cycle.rs, crates/perry-runtime/src/gc/roots.rs, crates/perry-runtime/src/gc/trace.rs, crates/perry-runtime/src/gc/tests/*, crates/perry-runtime/src/gc/tests/mod.rs, scripts/gc_runtime_root_holders.json, changelog.d/11664-gc-pinned-objects-are-roots.md
Marking and block-persistence paths use updated pinned-object handling. Tests cover child and promise-reaction survival across collection modes and allocation origins, including sabotage cases for the prior behaviors.

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
Loading

Suggested reviewers: claude

Merge Risk: ⚪ Minimal · up to c9682

The inspected cross-thread promise paths retain their GC roots. No actionable issue is established that would prevent merging after normal checks.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to c9682

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

  • Medium · security · inferred: A pin installed after a budgeted collection’s final root remark may not be traced in that collection: pin registration records a future root, but does not visibly shade the object for the active cycle. Reachability and regression against the former fallback remain unproven.
Security review details

Security Blast Radius

  • inferred — A missed trace can affect objects reachable only through a pinned parent in the collecting runtime thread, including promise reactions; the reviewed paths do not establish cross-thread heap ownership transfer.

Security Findings and Attack Paths

  • inferred — The unresolved attack-relevant transition is a newly effective pin after the final root remark but before sweep. No reachable caller or resulting use-after-free in that window was established.

Trust Boundaries and Controls

  • observed — Root-scanner registration and the malloc-object registry are thread-local. The reviewed cross-thread Promise path keeps allocation and pin registration with the owning thread.

Resilience and Maintainability Implications

  • observed — The active mark barrier is kept through the finalize-to-sweep gap and its seeds are drained before the sweep snapshot; the inspected pin setters publish summaries rather than invoking that barrier.

Hardening Proposals

  • proposed — Establish whether pins can become newly effective after final remark; if they can, ensure that transition shades the pinned object and traces its children before sweep.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the pinned-object GC fix and the associated use-after-free in cross-thread promises. It is specific and related to the primary change.
Description check ✅ Passed The description provides a detailed summary, concrete changes, root cause, test coverage, and verification results. It does not use the template headings or include the explicit checklist and related-…
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.
Full details: Docstring Coverage

Explanation

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.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@proggeramlug
proggeramlug merged commit 329bc71 into main Sep 29, 2026
23 checks passed
@proggeramlug
proggeramlug deleted the fix-pinned-objects-traced branch September 29, 2026 11:18
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