Skip to content

arr.push(f()) / arr.push(...g()) evaluate the receiver AFTER the argument, so a reassigning argument pushes onto the wrong array #7634

Description

@proggeramlug

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.

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