Skip to content

GC: { ...src } with an accessor source SIGSEGVs under PERRY_GC_MOVING_LOOP_POLLS=1 #7200

Closed

Description

@proggeramlug

Summary

{ ...src, k: v } where src carries an accessor SIGSEGVs (exit=139) under PERRY_GC_MOVING_LOOP_POLLS=1. Clean under the shipped default (polls off since #7161). A lighter variant of the same program does not crash but silently loses the copied accessor value 10 times in 400.

This is one of the residual sites of the #7154 class that #7184 and #7192 did not close, and it is a hard blocker for reverting #7161.

Reproducer

function churn(): number {
  const a: any[] = [];
  for (let i = 0; i < 600; i++) a.push({ i: i, s: "z" });
  return a.length;
}
const seen: any[] = [];
for (let r = 0; r < 400; r++) {
  const src: any = { plain: 5 };
  Object.defineProperty(src, "hot", { enumerable: true, get: function () { return churn(); } });
  const out: any = { ...src, tail: 7 };
  if (out.hot !== 600) seen.push(r + ":" + String(out.hot) + ":" + typeof out.hot);
}
console.log("bad", seen.length, seen.slice(0, 6).join(" "));
node --experimental-strip-types  →  bad 0
perry, default                   →  bad 0
perry, PERRY_GC_MOVING_LOOP_POLLS=1 (compile AND run)  →  exit=139, no output

Without the string building in the loop (console.log("plain", bp, "hot", bh, "tail", bt) counting each field separately) it does not crash, and reports plain 0 hot 10 tail 0 — so the destination object is intact and it is specifically the accessor's copied value that is lost.

Reproduced identically at 73a9084ea (before #7184 and #7192), so it is inherited, not caused by either.

Where it is

Not Expr::ObjectSpread — that arm is reached only from a JSX spread attribute (crates/perry-hir/src/jsx.rs:67, its sole construction site). Since #809 an object literal containing a spread lowers to a source-ordered IIFE built on js_object_assign_one (crates/perry-hir/src/lower/expr_object.rs:844), and that is the path here.

The accumulator in Expr::ObjectAssign's lowering (crates/perry-codegen/src/expr/logical_collections.rs) is threaded through a bare SSA register across each lower_expr(ctx, src) and across each js_object_assign_one call:

let mut acc = ctx.block().call(DOUBLE, "js_object_assign_validate_target", &[(DOUBLE, &target_box)]);
for src in sources {
    let src_box = lower_expr(ctx, src)?;
    acc = ctx.block().call(DOUBLE, "js_object_assign_one", &[(DOUBLE, &acc), (DOUBLE, &src_box)]);
}

js_object_assign_one reads every own key of the source, so an accessor there runs arbitrary user code inside the helper, which under polls can reach a back-edge poll and an evacuating minor with the caller's frame live. That is the same shape #7192 fixed for Expr::Object/ObjectSpread/ClassExprFresh, applied to the arm the syntax actually takes.

The evidence that the object is fine but the accessor's value is not points one layer further in — at the runtime helper's own handling of the value the getter returned — so the fix may be on both sides. js_object_assign_one's RuntimeHandleScope usage is the place to look.

Why it matters

{ ...props } is ubiquitous. Until this is closed, PERRY_GC_MOVING_LOOP_POLLS=1 cannot become the default again, i.e. #7161 cannot be reverted and #7019's minor-GC RSS win stays reverted.

Refs #7154, #7161, #7192, #809.

Activity

  1. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in #7207 — and the "Where it is" section here is also not where it is.

    { ...src, tail: 7 } does not reach Expr::ObjectAssign at all. The #809 IIFE emits a generic Expr::Call on js_object_assign_one (perry-hir/src/lower/expr_object.rs:1159-1163), and its __o is a Type::Any local — so it has a shadow slot, and is marked and rewritten. That is exactly why the lighter variant reports plain 0 hot 10 tail 0: the destination object is fine, as this issue observes.

    The SIGSEGV is inside the helper. js_object_get_field_by_name short-circuits into invoke_accessor_getter → clone_closure_rebind_this → js_closure_call0 — arbitrary user code inside a runtime helper — and target, src, src_keys and key_ptr are raw addresses in Rust locals, used after it returns: target and key_ptr by the write funnel on the very next line, src_keys/src by the next iteration. The collector rewrites roots; a Rust local is not one.

    The function already modelled the fix one branch up — the native-module arm opens a RuntimeHandleScope, roots target, and returns the handle-reloaded pointer. #7207 applies that to both numbered sections plus the array-source branch, the closure-source snapshot, the proxy-source loop (two traps per key — the widest window in the file), the string-source loop, the symbol tail, and the getter's own return value, and returns the post-collection target instead of the target_f64 captured on entry.

    The Expr::ObjectAssign accumulator this issue quotes is a real bug — Object.assign(t, f(), g()) — and is fixed alongside it: temp-rooted, re-read before each link, and republished after each, because the helper now hands back a moved address.

    test-files/test_gap_gc_spread_accessor_rooting.ts, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1: exit=139 (SIGSEGV) at origin/main (3a983ca6a), 3/3 → bad plain 0 hot 0 tail 0 5/5 with the fix, clean 3/3 under the shipped default, byte-exact vs Node 26.5.1.

  2. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Re-verified on main = d4ac65f21. Fixed — closing.

    Fixed by #7207 (64c1f56fb's predecessor: js_object_assign_one now opens a RuntimeHandleScope over both numbered sections, the array/proxy/string-source branches and the getter's return value) plus #7249 (64c1f56fb, the lazy globalThis bootstrap no-move window), which was the collection this reproducer was actually landing in.

    Note the correction #7207 already recorded: the cause named in this issue — the Expr::ObjectAssign accumulator — was not the site. { ...src, tail: 7 } never reaches that arm; the #809 IIFE emits a generic Expr::Call on js_object_assign_one, and the SIGSEGV was inside the helper, where js_object_get_field_by_name short-circuits into invoke_accessor_getter → js_closure_call0, i.e. arbitrary user code with target/src/src_keys/key_ptr live as raw Rust locals.

    Evidence

    Built --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static, PERRY_RUNTIME_DIR pinned, PERRY_NO_AUTO_OPTIMIZE=1. Compiled with no compile-time GC env; run on the allocation-point arm, which is the route that exposed this (loop_polls could not — #7249's general finding):

    PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
      ./test_gap_gc_spread_accessor_rooting
    
    result
    this issue's report exit=139, no output, 10/10
    d4ac65f21 bad plain 0 hot 0 tail 0, 10/10, byte-exact vs node 26.5.1

    The arm was live, not merely quiet: PERRY_GC_DIAG=1 on the same binary and arm reports 5 copying minors, 17 861 objects copied. A green run at zero relocations would not have been accepted.

    Part of the sweep in #7262.

  3. added a commit that references this issue on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions