Severity: P1 (wrong lifecycle + latent UAF)
Found by: audit fable-audit-perry-2.md, late-catch confirmed by probe.
What
Unhandled promise rejections are reported only at process exit, there is no process.on('unhandledRejection')/'rejectionHandled'), and a .catch attached in any later macrotask silently suppresses what Node would treat as a fatal unhandled rejection.
const p = Promise.reject(new Error("boom"));
setTimeout(() => p.catch(() => console.log("late")), 10);
// node (default): crashes before the timer, nonzero exit
// perry: prints "late", exit 0
Why
Rejections with no reaction are tracked in static UNHANDLED_REJECTIONS: RefCell<Vec<usize>> (crates/perry-runtime/src/promise/then.rs:49-50) and only inspected by js_promise_report_unhandled_rejections(), which codegen emits after the event loop exits (crates/perry-codegen/src/codegen/entry.rs:914-918, logic at then.rs:119-194). Three problems:
- Timing — Node fires per-rejection at end-of-turn; Perry defers to process end, so a later handler suppresses it.
- No
process emitter path at all (zero unhandledRejection hits in runtime+stdlib) — log-and-continue programs get exit(1) instead.
UNHANDLED_REJECTIONS entries are not GC roots (no scanner references them) and the exit-time report derefs (*pr).reason — a swept/recycled promise makes that a stale read (UB; arena pages stay mapped so misreport is likelier than a hard fault).
Fix
Check the tracked set at the end of each run_microtasks drain (non-reentrant, timers-allowed mode) instead of at exit; root the reasons (or pin the promise while tracked); add a process emitter hook before the exit(1) fallback so handlers can observe/suppress.
Confidence: high on mechanism; UAF exploitability medium.
Severity: P1 (wrong lifecycle + latent UAF)
Found by: audit
fable-audit-perry-2.md, late-catch confirmed by probe.What
Unhandled promise rejections are reported only at process exit, there is no
process.on('unhandledRejection')/'rejectionHandled'), and a.catchattached in any later macrotask silently suppresses what Node would treat as a fatal unhandled rejection.Why
Rejections with no reaction are tracked in
static UNHANDLED_REJECTIONS: RefCell<Vec<usize>>(crates/perry-runtime/src/promise/then.rs:49-50) and only inspected byjs_promise_report_unhandled_rejections(), which codegen emits after the event loop exits (crates/perry-codegen/src/codegen/entry.rs:914-918, logic atthen.rs:119-194). Three problems:processemitter path at all (zerounhandledRejectionhits in runtime+stdlib) — log-and-continue programs getexit(1)instead.UNHANDLED_REJECTIONSentries are not GC roots (no scanner references them) and the exit-time report derefs(*pr).reason— a swept/recycled promise makes that a stale read (UB; arena pages stay mapped so misreport is likelier than a hard fault).Fix
Check the tracked set at the end of each
run_microtasksdrain (non-reentrant, timers-allowed mode) instead of at exit; root the reasons (or pin the promise while tracked); add aprocessemitter hook before theexit(1)fallback so handlers can observe/suppress.Confidence: high on mechanism; UAF exploitability medium.