Found while auditing crates/perry-codegen/src/expr/array_push.rs for the Layer 1
rooting migration (#7615, slice 3). Not a rooting bug — the opposite: the arm is
rooting-safe because it evaluates in an order the spec does not permit.
The divergence
let a: number[] = [1];
function f(): number { a = [9]; return 2; }
a.push(f());
console.log(JSON.stringify(a));
let b: number[] = [1];
function g(): number[] { b = [9]; return [2, 3]; }
b.push(...g());
console.log(JSON.stringify(b));
|
output |
node --experimental-strip-types (26.5.1) |
[9] / [9] |
perry (main, PERRY_NO_AUTO_OPTIMIZE=1) |
[9,2] / [9,2,3] |
Per ES2024 the MemberExpression a.push is evaluated to a Reference before
the argument list, so the push lands on the array a named at that moment — the
one the program then throws away. Perry pushes onto the array a names
afterwards.
Where
Both arms of expr/array_push.rs lower the value first and the receiver second:
// Expr::ArrayPush
let (v, v_bits) = lower_array_push_value(ctx, value, ...)?; // arbitrary user code
let arr_box = lower_expr(ctx, &array_expr)?; // Expr::LocalGet, a slot read
// Expr::ArrayPushSpread
let src_box = lower_expr(ctx, source)?; // arbitrary user code
let arr_box = lower_expr(ctx, &Expr::LocalGet(*array_id))?;
It is only observable when the argument reassigns the receiver's binding, which
requires the binding to be captured-and-mutated (so it is boxed) or a module
global — otherwise the two orders name the same array.
Why it is not fixed in #7615's slice 3
Reading the receiver after the value is exactly what makes the arm free of
rooting work today: the load observes whatever an evacuating cycle wrote back
into the local's alloca / box / module global, so no register is stale and
operand_protection answers Reuse. Restoring spec order means the receiver is
live across the value's lowering, i.e. it has to be rooted:
rooting::with_operands_rooted_across(
ctx,
&[&array_expr], // the receiver, rooted first
&[value], // does the window collect?
|ctx| lower_array_push_value(ctx, value, layout_note_needed, write_barrier_needed),
|ctx, vals, (v, v_bits)| { /* the existing tiers, on vals[0] */ },
)
That is expressible with the existing combinator and needs no new one. What it
costs is the question: a.push(<anything that can collect>) is one of the most
frequently emitted lowerings in the compiler, and every such push would gain a
temp-root push / re-read / release. Numeric pushes (a.push(i * 2)) are
unaffected — operand_protection still answers Reuse when the value provably
cannot collect — but out.push(f(x)) and rows.push({...}) are not.
So the fix needs a measured before/after on the pinned mini, which a
behaviour-preserving refactor slice has no mandate to do. Filed here instead, per
the same rule that produced #7628.
Suggested acceptance
- a
test-files/test_gap_*.ts covering both arms (the snippet above), registered
in the gap corpus;
- the reorder above, with the temp-root cost measured on
churn_alloc /
push_cls before and after.
Found while auditing
crates/perry-codegen/src/expr/array_push.rsfor the Layer 1rooting migration (#7615, slice 3). Not a rooting bug — the opposite: the arm is
rooting-safe because it evaluates in an order the spec does not permit.
The divergence
node --experimental-strip-types(26.5.1)[9]/[9]main,PERRY_NO_AUTO_OPTIMIZE=1)[9,2]/[9,2,3]Per ES2024 the
MemberExpressiona.pushis evaluated to a Reference beforethe argument list, so the push lands on the array
anamed at that moment — theone the program then throws away. Perry pushes onto the array
anamesafterwards.
Where
Both arms of
expr/array_push.rslower the value first and the receiver second:It is only observable when the argument reassigns the receiver's binding, which
requires the binding to be captured-and-mutated (so it is boxed) or a module
global — otherwise the two orders name the same array.
Why it is not fixed in #7615's slice 3
Reading the receiver after the value is exactly what makes the arm free of
rooting work today: the load observes whatever an evacuating cycle wrote back
into the local's alloca / box / module global, so no register is stale and
operand_protectionanswersReuse. Restoring spec order means the receiver islive across the value's lowering, i.e. it has to be rooted:
That is expressible with the existing combinator and needs no new one. What it
costs is the question:
a.push(<anything that can collect>)is one of the mostfrequently emitted lowerings in the compiler, and every such push would gain a
temp-root push / re-read / release. Numeric pushes (
a.push(i * 2)) areunaffected —
operand_protectionstill answersReusewhen the value provablycannot collect — but
out.push(f(x))androws.push({...})are not.So the fix needs a measured before/after on the pinned mini, which a
behaviour-preserving refactor slice has no mandate to do. Filed here instead, per
the same rule that produced #7628.
Suggested acceptance
test-files/test_gap_*.tscovering both arms (the snippet above), registeredin the gap corpus;
churn_alloc/push_clsbefore and after.