Skip to content

fix(runtime): root promises tracked for unhandled rejection (UAF) (#6077)#6209

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6077-unhandled-rejection
Jul 9, 2026
Merged

fix(runtime): root promises tracked for unhandled rejection (UAF) (#6077)#6209
proggeramlug merged 1 commit into
mainfrom
fix/6077-unhandled-rejection

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The unhandled-rejection tracking set (UNHANDLED_REJECTIONS, promise/then.rs) stores raw promise addresses but had no GC root scanner. A regular promise is allocated in the movable arena, so a tracked promise could be swept or evacuated before the program-end report — at which point js_promise_report_unhandled_rejections dereferences (*pr).state / (*pr).reason through a stale/forwarded pointer (a use-after-free). Arena pages stay mapped, so it reads a recycled promise (silent misreport / wrong reason) more often than it hard-faults.

Addresses the memory-safety half of #6077 (the "latent UAF" the issue flags).

Fix

Register scan_unhandled_rejection_roots_mut as a mutable GC root scanner (mirroring the ~55 existing side-table scanners). It visits each tracked address with visit_usize_slot, which marks the promise live and rewrites the stored pointer on evacuation. Entries are already removed the moment a reaction is wired (mark_rejection_handled) or the promise settles handled, so this never keeps a dead promise alive past its own report.

Validation

  • Under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 (panics if a live slot still points at a forwarded object): a distinctive unhandled rejection driven through heavy allocation reports its exact reason, no panic, no corruption. Multi-rejection stress likewise clean.
  • 25 promise/async gap tests green; normal resolve/reject/await behavior unchanged.
  • cargo fmt + GC store-site inventory clean.

Scope note

This is the memory-safety half. The other two parts of #6077 — end-of-turn reporting timing (so a late .catch no longer suppresses) and the process.on('unhandledRejection'/'rejectionHandled') emitter — are a behavior change with real blast radius on rejection semantics (default-crash-on-unhandled), so they're left as a documented follow-up rather than bundled into this UAF fix.

Code-only per contributor convention (no version/CHANGELOG bump).

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of unhandled promise rejections so tracked promise data stays valid until final reporting.
    • Reduced the risk of missing or stale rejection details at program end.

)

The unhandled-rejection set (`UNHANDLED_REJECTIONS` in `promise/then.rs`) stores
raw promise addresses, but no GC root scanner referenced it. A regular promise
is allocated in the MOVABLE arena (`js_promise_new_with_parent_impl`), so a
tracked promise could be swept or evacuated before the program-end report — at
which point `js_promise_report_unhandled_rejections` dereferences `(*pr).state`
/ `(*pr).reason` through a stale/forwarded pointer. Arena pages stay mapped, so
this reads a recycled promise (silent misreport / wrong "Uncaught (in promise)"
reason) more often than it hard-faults, but it is a use-after-free either way.

Register `scan_unhandled_rejection_roots_mut` as a mutable GC root scanner: it
visits each tracked address with `visit_usize_slot`, which marks the promise
live AND rewrites the stored pointer on evacuation. Entries are already removed
from the set the moment a reaction is wired (`mark_rejection_handled`) or the
promise settles handled, so this never keeps a genuinely-dead promise alive past
its own report.

Validated: under `PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1` (which
panics if a live slot still points at a forwarded object), a distinctive
unhandled rejection driven through heavy allocation reports its exact reason with
no panic and no corruption; 25 promise/async gap tests stay green; normal promise
resolve/reject/await behavior is unchanged.

Scope note: this fixes the memory-safety half of #6077. The end-of-turn
reporting timing (so a late `.catch` no longer suppresses) and the
`process.on('unhandledRejection'/'rejectionHandled')` emitter are a separate
behavior change (blast radius on rejection semantics) left as a follow-up.
@coderabbitai

coderabbitai Bot commented Jul 9, 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: 64cfd801-0483-4609-b451-b9b1f404c94f

📥 Commits

Reviewing files that changed from the base of the PR and between c78903c and 5f82061.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/promise/then.rs

📝 Walkthrough

Walkthrough

This PR adds a GC root scanner for unhandled promise rejections. A new function scans UNHANDLED_REJECTIONS and reports tracked promise addresses to the GC root visitor, is publicly re-exported from the promise module, and is registered in gc_init to keep this data alive until end-of-program reporting.

Changes

Unhandled Rejection GC Root Scanner

Layer / File(s) Summary
Root scanner implementation and export
crates/perry-runtime/src/promise/then.rs, crates/perry-runtime/src/promise/mod.rs
Adds scan_unhandled_rejection_roots_mut, which iterates tracked promise address slots in UNHANDLED_REJECTIONS and reports each to the GC root visitor; re-exports this function from the promise module's public API.
GC init registration
crates/perry-runtime/src/gc/mod.rs
Registers the new scanner call within gc_init's root-scanner setup so tracked promises remain address-stable until unhandled rejection reporting runs.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5496: Both modify the unhandled-rejection reporting pipeline in promise/then.rs, one adding GC root scanning and the other improving stack trace output.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding GC rooting for tracked unhandled-rejection promises to address a UAF.
Description check ✅ Passed The description covers the summary, fix, validation, and scope well, though it doesn't mirror every template section explicitly.
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/6077-unhandled-rejection

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 b8cf5d3 into main Jul 9, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6077-unhandled-rejection branch July 9, 2026 20:58
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