Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions changelog.d/7065-closure-self-pointer-gc-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Fixed

- **A relocating young collection no longer invalidates a closure's own `this_closure` pointer** (#7055). A closure body reaches its captures through `js_closure_get_capture_bits(%this_closure, idx)`, and `%this_closure` is an LLVM parameter — a register value no root enumeration can see. The shipped default runs an evacuating young collection at loop back-edge polls (`js_gc_loop_safepoint`) with precise roots and no conservative native-stack scan, so a closure relocated while its own body was on the stack left that register pointing into from-space; the same cycle resets from-space and hands it straight back to the mutator, after which `js_closure_get_capture_bits` read a foreign object's `capture_count`, judged the index out of range, and returned **0**. Pointer 0 is not a registered box, so every later boxed-capture read yielded `undefined` and every boxed-capture write was silently dropped.

In an `async fn` the casualty was the generator state machine itself: the async-to-generator transform boxes every body local, `__gen_state` included, so the `state = <next>` store at the end of a resumed state body went nowhere and the following `await` resumed into the state it had just finished — **replaying one loop iteration** and folding its contribution into the accumulator twice. That is the shape #7055 reported on an ordinary event-loop request pump: a deterministic checksum error of *fixed* magnitude at 150 / 300 / 600 / 1200 requests, present only when `copied_objects > 0` and absent under `PERRY_GEN_GC=0`.

Closure bodies with captures now spill `%this_closure` into a NaN-boxed entry alloca bound to a shadow-stack slot (`reserve_shadow_slot` + `js_shadow_slot_bind`), and `expr::current_closure_ptr_value` reloads the pointer from that slot at every capture access — so the collector marks and rewrites it like any other precise root. Capture-less closures emit no capture access and keep their frame-free prologue, so `(a, b) => a - b` costs nothing. The typed-ABI clone is untouched and is not a hole: `lower_typed_f64_body_*` bails on anything that is not straight-line arithmetic, so a typed body contains no poll and no allocation. No runtime change — `crates/perry-runtime` is byte-identical.

Minimal reproducer (11 lines; `calls` must be 3, printed 4 under any relocating arm):

```ts
let calls = 0;
async function main(): Promise<void> {
for (let r = 0; r < 3; r++) {
calls = calls + 1;
let o: any = null;
for (let i = 0; i < 50; i++) { o = { id: i }; }
await Promise.resolve();
}
console.log("calls:" + calls);
}
main();
```

The issue's `w5_srv_scale.ts` is now byte-exact against node 26.5.0 at 150 / 300 / 600 requests in the shipped default, and across `PERRY_GC_SCAVENGE_NURSERY_MB` 1–16, `PERRY_GC_MOVING_SAFEPOINT=0`, `PERRY_GC_SCAVENGE=1`, `PERRY_GC_FORCE_EVACUATE=1` and `PERRY_GEN_GC=0`. Covered by a `cargo-test`-visible codegen IR-shape test and `crates/perry/tests/gc_closure_self_pointer_root_7055.rs`, both sabotage-verified in both directions.
8 changes: 7 additions & 1 deletion crates/perry-codegen/src/codegen/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ pub(crate) fn materialize_arguments_object(
.call(I64, "js_closure_alloc_singleton", &[(PTR, &wrap_ref)]);
nanbox_pointer_inline(ctx.block(), &closure_ptr)
}
ArgumentsCallee::CurrentClosure => nanbox_pointer_inline(ctx.block(), "%this_closure"),
ArgumentsCallee::CurrentClosure => {
// #7055: through the shadow-rooted slot when there is one — the
// raw `%this_closure` register is not a GC root.
let ptr = crate::expr::try_current_closure_ptr_value(ctx)
.unwrap_or_else(|| "%this_closure".to_string());
nanbox_pointer_inline(ctx.block(), &ptr)
}
}
};
let args_obj = ctx.block().call(
Expand Down
365 changes: 365 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ pub(super) fn compile_module_entry(
namespace_v8_specifiers: &cross_module.namespace_v8_specifiers,
closure_captures: HashMap::new(),
current_closure_ptr: None,
current_closure_slot: None,
enums,
is_async_fn: false,
is_strict_fn: false,
Expand Down Expand Up @@ -1333,6 +1334,7 @@ pub(super) fn compile_module_entry(
namespace_v8_specifiers: &cross_module.namespace_v8_specifiers,
closure_captures: HashMap::new(),
current_closure_ptr: None,
current_closure_slot: None,
enums,
is_async_fn: false,
is_strict_fn: false,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ pub(super) fn compile_function(
namespace_v8_specifiers: &cross_module.namespace_v8_specifiers,
closure_captures: HashMap::new(),
current_closure_ptr: None,
current_closure_slot: None,
enums,
is_async_fn: f.is_async,
is_strict_fn: f.is_strict,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ pub(super) fn compile_method(
namespace_v8_specifiers: &cross_module.namespace_v8_specifiers,
closure_captures: HashMap::new(),
current_closure_ptr: None,
current_closure_slot: None,
enums,
is_async_fn: method.is_async,
is_strict_fn: true,
Expand Down Expand Up @@ -1498,6 +1499,7 @@ pub(super) fn compile_static_method(
namespace_v8_specifiers: &cross_module.namespace_v8_specifiers,
closure_captures: HashMap::new(),
current_closure_ptr: None,
current_closure_slot: None,
enums,
is_async_fn: f.is_async,
is_strict_fn: f.is_strict,
Expand Down
20 changes: 7 additions & 13 deletions crates/perry-codegen/src/expr/array_push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
if ctx.boxed_vars.contains(array_id) {
// Captured-through-closure boxed var.
if let Some(&capture_idx) = ctx.closure_captures.get(array_id) {
let closure_ptr = ctx.current_closure_ptr.clone().ok_or_else(|| {
anyhow!("ArrayPush boxed captured but no current_closure_ptr")
})?;
let closure_ptr =
super::current_closure_ptr_value(ctx, "ArrayPush boxed captured")?;
let idx_str = capture_idx.to_string();
let blk = ctx.block();
let box_ptr = blk.call(
Expand Down Expand Up @@ -477,10 +476,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// module-global store-back below instead of returning.
}
if let Some(&capture_idx) = ctx.closure_captures.get(array_id) {
let closure_ptr = ctx
.current_closure_ptr
.clone()
.ok_or_else(|| anyhow!("ArrayPush captured but no current_closure_ptr"))?;
let closure_ptr = super::current_closure_ptr_value(ctx, "ArrayPush captured")?;
let idx_str = capture_idx.to_string();
let new_bits = ctx.block().bitcast_double_to_i64(&new_box);
ctx.block().call_void(
Expand Down Expand Up @@ -526,9 +522,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let new_box = nanbox_pointer_inline(blk, &new_handle);
if ctx.boxed_vars.contains(array_id) {
if let Some(&capture_idx) = ctx.closure_captures.get(array_id) {
let closure_ptr = ctx.current_closure_ptr.clone().ok_or_else(|| {
anyhow!("ArrayPushSpread boxed captured but no current_closure_ptr")
})?;
let closure_ptr =
super::current_closure_ptr_value(ctx, "ArrayPushSpread boxed captured")?;
let idx_str = capture_idx.to_string();
let blk = ctx.block();
let box_ptr = blk.call(
Expand Down Expand Up @@ -562,9 +557,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// GC-root slot (see the matching note in `Expr::ArrayPush`).
}
if let Some(&capture_idx) = ctx.closure_captures.get(array_id) {
let closure_ptr = ctx.current_closure_ptr.clone().ok_or_else(|| {
anyhow!("ArrayPushSpread captured but no current_closure_ptr")
})?;
let closure_ptr =
super::current_closure_ptr_value(ctx, "ArrayPushSpread captured")?;
let idx_str = capture_idx.to_string();
let new_bits = ctx.block().bitcast_double_to_i64(&new_box);
ctx.block().call_void(
Expand Down
7 changes: 3 additions & 4 deletions crates/perry-codegen/src/expr/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! Pure mechanical move — match arm bodies are verbatim copies, called from
//! `lower_expr`'s outer dispatch.

use anyhow::{anyhow, Result};
use anyhow::Result;
use perry_hir::Expr;

use crate::type_analysis::compute_auto_captures;
Expand Down Expand Up @@ -91,9 +91,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// transitively-captured box. Read the
// capture slot RAW (it holds the box ptr
// bits) and propagate directly.
let closure_ptr = ctx.current_closure_ptr.clone().ok_or_else(|| {
anyhow!("nested boxed capture but no current_closure_ptr")
})?;
let closure_ptr =
super::current_closure_ptr_value(ctx, "nested boxed capture")?;
let idx_str = _capture_idx.to_string();
let v = ctx.block().call(
I64,
Expand Down
7 changes: 2 additions & 5 deletions crates/perry-codegen/src/expr/instance_misc1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! Pure mechanical move — match arm bodies are verbatim copies, called from
//! `lower_expr`'s outer dispatch.

use anyhow::{anyhow, Result};
use anyhow::Result;
use perry_hir::{BinaryOp, Expr, WithSetFallback};

use crate::nanbox::{double_literal, i64_literal, POINTER_MASK_I64};
Expand Down Expand Up @@ -82,10 +82,7 @@ fn emit_with_key(ctx: &mut FnCtx<'_>, property: &str) -> (String, String) {
fn store_prelowered_local(ctx: &mut FnCtx<'_>, id: u32, value: &str) -> Result<String> {
super::invalidate_local_write_facts(ctx, id);
if let Some(&capture_idx) = ctx.closure_captures.get(&id) {
let closure_ptr = ctx
.current_closure_ptr
.clone()
.ok_or_else(|| anyhow!("captured with-fallback set but no current_closure_ptr"))?;
let closure_ptr = super::current_closure_ptr_value(ctx, "captured with-fallback set")?;
let idx_str = capture_idx.to_string();
if ctx.boxed_vars.contains(&id) {
let blk = ctx.block();
Expand Down
44 changes: 30 additions & 14 deletions crates/perry-codegen/src/expr/literals_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! Pure mechanical move — match arm bodies are verbatim copies, called from
//! `lower_expr`'s outer dispatch.

use anyhow::{anyhow, Result};
use anyhow::Result;
use perry_hir::types::Type as HirType;
use perry_hir::{BinaryOp, Expr, UpdateOp};

Expand Down Expand Up @@ -391,10 +391,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
// Captured by closure (from outer scope):
if let Some(&capture_idx) = ctx.closure_captures.get(id) {
let closure_ptr = ctx
.current_closure_ptr
.clone()
.ok_or_else(|| anyhow!("captured local but no current_closure_ptr"))?;
let closure_ptr = super::current_closure_ptr_value(ctx, "captured local")?;
let idx_str = capture_idx.to_string();
// If the captured id is a boxed var, the capture slot holds a
// raw box pointer. Read the capture, extract the box pointer,
Expand Down Expand Up @@ -578,10 +575,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// Closure captures first (write through the runtime), then
// locals, then module globals.
if let Some(&capture_idx) = ctx.closure_captures.get(id) {
let closure_ptr = ctx
.current_closure_ptr
.clone()
.ok_or_else(|| anyhow!("captured local set but no current_closure_ptr"))?;
let closure_ptr = super::current_closure_ptr_value(ctx, "captured local set")?;
let idx_str = capture_idx.to_string();
// Boxed captured var: read the box pointer from the
// capture slot, then js_box_set_bits to update the shared
Expand Down Expand Up @@ -709,12 +703,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
};
// Closure capture path: runtime get + add/sub + runtime set.
if let Some(&capture_idx) = ctx.closure_captures.get(id) {
let closure_ptr = ctx
.current_closure_ptr
.clone()
.ok_or_else(|| anyhow!("captured local update but no current_closure_ptr"))?;
let closure_ptr = super::current_closure_ptr_value(ctx, "captured local update")?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let idx_str = capture_idx.to_string();
// Boxed captured var: deref box bits, modify, store back.
//
// `box_ptr` deliberately survives the `coerce_old`/`step_new`
// calls below even though those can collect: a box is
// `std::alloc::alloc`'d by `js_box_alloc_bits`, is never freed
// and is never relocated (`scan_box_roots_mut` rewrites the
// JSValue *inside* the box, not the box's address), so an
// address read before a collection still names the same live
// cell after it. The closure pointer has no such guarantee,
// which is why the non-boxed arm below re-reads it.
if ctx.boxed_vars.contains(id) {
let blk = ctx.block();
let box_ptr = blk.call(
Expand Down Expand Up @@ -743,7 +743,23 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let old = coerce_old(blk, &old);
let new = step_new(blk, &old);
let new_bits = blk.bitcast_double_to_i64(&new);
blk.call_void(
// #7055: `coerce_old` emits `js_to_numeric`, which runs a user
// `valueOf` — arbitrary JS, including allocating loops that
// reach a `js_gc_loop_safepoint` and relocate this very
// closure. `js_closure_set_capture_bits` does NOT validate its
// pointer (unlike `js_closure_get_capture_bits`, which bounds-
// checks and returns 0), so writing through a pre-coercion
// `closure_ptr` would store into whatever the mutator has since
// put at that recycled from-space address. Re-read the rooted
// slot after the coercion; the collector has rewritten it.
// Only the coercing path pays the reload — a statically
// integer-typed counter emits no call in between.
let closure_ptr = if needs_numeric_coerce {
super::current_closure_ptr_value(ctx, "captured local update")?
} else {
closure_ptr
};
ctx.block().call_void(
"js_closure_set_capture_bits",
&[(I64, &closure_ptr), (I32, &idx_str), (I64, &new_bits)],
);
Expand Down
30 changes: 22 additions & 8 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! error so a user running `--backend llvm` on richer TypeScript gets a
//! one-line explanation instead of a silent broken binary.

use anyhow::{anyhow, Result};
use anyhow::Result;
use perry_hir::types::Type as HirType;
use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp};

Expand Down Expand Up @@ -147,9 +147,10 @@ pub(crate) use scalar_slot_root::{
root_scalar_replaced_slot, root_scalar_replaced_slot_unconditional,
};
pub(crate) use shadow_slot::{
emit_persistent_shadow_root_barrier, emit_shadow_slot_bind_for_local, emit_shadow_slot_clear,
emit_shadow_slot_update_for_expr, enable_persistent_shadow_slot_for_array_alias,
expr_is_known_non_pointer_shadow_value,
current_closure_ptr_value, emit_persistent_shadow_root_barrier,
emit_shadow_slot_bind_for_local, emit_shadow_slot_clear, emit_shadow_slot_update_for_expr,
enable_persistent_shadow_slot_for_array_alias, expr_is_known_non_pointer_shadow_value,
try_current_closure_ptr_value,
};

/// One in-flight inline-constructor return target. See
Expand Down Expand Up @@ -343,7 +344,23 @@ pub(crate) struct FnCtx<'a> {
/// Inside a closure body, the LLVM SSA value name for the current
/// closure pointer (`%this_closure`). `Expr::LocalGet` of a captured
/// id uses this as the first arg to `js_closure_get_capture_bits`.
///
/// Prefer [`current_closure_ptr_value`] over reading this directly — the
/// raw SSA parameter is NOT a GC root and goes stale the moment a
/// relocating collection runs inside the body (#7055).
pub current_closure_ptr: Option<String>,
/// Inside a closure body, the entry-block alloca holding the NaN-boxed
/// `%this_closure` pointer, bound to a shadow-stack slot so the moving
/// collector marks AND rewrites it (#7055).
///
/// `%this_closure` itself lives only in a register, and the copying minor
/// at a loop back-edge poll (`js_gc_loop_safepoint`) runs with precise
/// roots and no conservative stack scan — so a closure relocated mid-body
/// left every later `js_closure_get_capture_bits` reading recycled
/// from-space memory, silently returning 0 and turning every capture
/// read/write into a no-op. Reload through this slot at every capture
/// access instead.
pub current_closure_slot: Option<String>,
/// Map from (enum_name, member_name) → enum value. Built once in
/// `compile_module` from `hir.enums`. Used by `Expr::EnumMember`
/// to lower enum references to constants.
Expand Down Expand Up @@ -1864,10 +1881,7 @@ pub(crate) fn is_compiler_private_async_i1_control_local(ctx: &FnCtx<'_>, id: u3

pub(crate) fn load_boxed_local_pointer(ctx: &mut FnCtx<'_>, id: u32) -> Result<Option<String>> {
if let Some(&capture_idx) = ctx.closure_captures.get(&id) {
let closure_ptr = ctx
.current_closure_ptr
.clone()
.ok_or_else(|| anyhow!("boxed local capture but no current_closure_ptr"))?;
let closure_ptr = current_closure_ptr_value(ctx, "boxed local capture")?;
let cap_bits = ctx.block().call(
I64,
"js_closure_get_capture_bits",
Expand Down
Loading
Loading