From 540c32bf8d61ffdc572a9cef63f0ad26faff19a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 15:09:31 +0200 Subject: [PATCH] fix(gc): await polled a promise the event-loop pump had already moved The await loop's `wait` block calls js_promise_run_microtasks_await_loop, js_run_stdlib_pump and js_await_loop_tick_timers -- all of which allocate and can drive an evacuating minor -- then branches back to `check`, which re-unboxes the SAME SSA value. After one pump the loop polls retired from-space and js_promise_state dereferences it. The comment already there -- 'unbox the promise in each block that uses it' -- solves LLVM's dominance requirement, which is a different problem. The box names the pre-collection promise, so unboxing it again in each of the five blocks reproduced the stale address rather than fixing it. The promise now takes one temp root and every block re-reads the slot the collector rewrites. 4/4 cluster tests clean (6/6 faults before, 0/6 after), byte-identical to Node. Closes 4 of the 31 catches in #7341, all obj_type=5 (GC_TYPE_PROMISE) with frame #1 in generated code. --- changelog.d/7375-await-promise-rooting.md | 19 +++++++++++++++++++ crates/perry-codegen/src/expr/fs_await.rs | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 changelog.d/7375-await-promise-rooting.md diff --git a/changelog.d/7375-await-promise-rooting.md b/changelog.d/7375-await-promise-rooting.md new file mode 100644 index 0000000000..1d5b71e27b --- /dev/null +++ b/changelog.d/7375-await-promise-rooting.md @@ -0,0 +1,19 @@ +### Fixed + +- **`await` polled a promise that the event-loop pump had already moved.** The + await loop's `wait` block calls `js_promise_run_microtasks_await_loop`, + `js_run_stdlib_pump` and `js_await_loop_tick_timers` — all of which allocate + and can drive an evacuating minor — then branches back to `check`, which + re-unboxes the *same* SSA value. After one pump the loop polled retired + from-space, and `js_promise_state` dereferenced it. + + The existing comment there ("unbox the promise in each block that uses it") + addresses LLVM's dominance requirement, which is a different problem: the box + itself named the pre-collection promise, so unboxing it again in every block + reproduced the stale address five times rather than fixing it. + + The promise now takes a temp root once, and every block re-reads the slot the + collector rewrites instead of reusing the register. + + Closes 4 of the 31 quarantine catches in #7341 — all `obj_type=5` + (`GC_TYPE_PROMISE`) with frame #1 in generated code. diff --git a/crates/perry-codegen/src/expr/fs_await.rs b/crates/perry-codegen/src/expr/fs_await.rs index 5e7b4d37d1..af6a9ecc91 100644 --- a/crates/perry-codegen/src/expr/fs_await.rs +++ b/crates/perry-codegen/src/expr/fs_await.rs @@ -93,6 +93,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // intermediate block. Hoisted to the entry block so the // slot dominates the merge block even when this Await is // itself nested inside an if-arm. + // #7341: root the promise for the whole await loop. + // + // `wait` calls `js_promise_run_microtasks_await_loop`, + // `js_run_stdlib_pump` and `js_await_loop_tick_timers`, every one of + // which allocates and can drive an evacuating minor — then branches + // back to `check`, which re-unboxes the SAME SSA value. The comment + // below about unboxing per block solves LLVM dominance, not GC + // movement: the box names the pre-collection promise, so after one + // pump the loop polls retired from-space and `js_promise_state` + // dereferences it. + // + // 4 of the 31 catches in #7341 are this, all `obj_type=5` + // (GC_TYPE_PROMISE) with frame #1 in generated code. The temp-root + // slot is what the collector rewrites, so every block re-reads it + // instead of reusing the register. + let promise_root = crate::expr::temp_root::temp_root_push_double(ctx, &promise_box); let result_slot = ctx.func.alloca_entry(DOUBLE); // Pre-seed with the boxed operand so the non-promise // branch just needs to jump to merge. @@ -144,6 +160,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // could hoist the unbox into (check is reachable from // both the initial branch AND from `wait`). ctx.current_block = check_idx; + let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); let promise_handle = unbox_to_i64(ctx.block(), &promise_box); let state = ctx .block() @@ -178,6 +195,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let wait_for_event_label = ctx.block_label(wait_for_event_idx); let unsettled_exit_label = ctx.block_label(unsettled_exit_idx); + let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); let promise_handle_wait = unbox_to_i64(ctx.block(), &promise_box); let state_after_tick = ctx.block() @@ -214,6 +232,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // === settled === ctx.current_block = settled_idx; + let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); let promise_handle2 = unbox_to_i64(ctx.block(), &promise_box); let state2 = ctx .block() @@ -230,6 +249,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // would terminate the process because `js_throw` longjmps // through a non-existent handler frame. ctx.current_block = reject_idx; + let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); let promise_handle3 = unbox_to_i64(ctx.block(), &promise_box); let reason = ctx .block() @@ -246,6 +266,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // === done === ctx.current_block = done_idx; + let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); let promise_handle4 = unbox_to_i64(ctx.block(), &promise_box); let value = ctx .block()