Repository navigation
Description
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.
Activity
proggeramlug commentedon Aug 1, 2026
Fixed in #7207 — and the "Where it is" section here is also not where it is.
{ ...src, tail: 7 }does not reachExpr::ObjectAssignat all. The #809 IIFE emits a genericExpr::Callonjs_object_assign_one(perry-hir/src/lower/expr_object.rs:1159-1163), and its__ois aType::Anylocal — so it has a shadow slot, and is marked and rewritten. That is exactly why the lighter variant reportsplain 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_nameshort-circuits intoinvoke_accessor_getter→clone_closure_rebind_this→js_closure_call0— arbitrary user code inside a runtime helper — andtarget,src,src_keysandkey_ptrare raw addresses in Rust locals, used after it returns:targetandkey_ptrby the write funnel on the very next line,src_keys/srcby 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, rootstarget, 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 thetarget_f64captured on entry.The
Expr::ObjectAssignaccumulator 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 withPERRY_GC_MOVING_LOOP_POLLS=1:exit=139(SIGSEGV) atorigin/main(3a983ca6a), 3/3 →bad plain 0 hot 0 tail 05/5 with the fix, clean 3/3 under the shipped default, byte-exact vs Node 26.5.1.fix(gc): close the three residual root-store holes — the PutValueSet …
ci(gc): make the moving-GC stale-root witnesses able to fail (+ #7205) (
proggeramlug commentedon Aug 2, 2026
Re-verified on
main=d4ac65f21. Fixed — closing.Fixed by #7207 (
64c1f56fb's predecessor:js_object_assign_onenow opens aRuntimeHandleScopeover both numbered sections, the array/proxy/string-source branches and the getter's return value) plus #7249 (64c1f56fb, the lazyglobalThisbootstrap 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::ObjectAssignaccumulator — was not the site.{ ...src, tail: 7 }never reaches that arm; the #809 IIFE emits a genericExpr::Callonjs_object_assign_one, and the SIGSEGV was inside the helper, wherejs_object_get_field_by_nameshort-circuits intoinvoke_accessor_getter→js_closure_call0, i.e. arbitrary user code withtarget/src/src_keys/key_ptrlive as raw Rust locals.Evidence
Built
--profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static,PERRY_RUNTIME_DIRpinned,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_pollscould not — #7249's general finding):exit=139, no output, 10/10d4ac65f21bad plain 0 hot 0 tail 0, 10/10, byte-exact vs node 26.5.1The arm was live, not merely quiet:
PERRY_GC_DIAG=1on 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.
codegen tests: the #7200 Object.assign rooting guard covers #14499's …