Repository navigation
correctness: a 3+ operand + chain reads all operands before the adds — a mutating valueOf/toString sees a stale later operand #10904
Description
Summary
In a left-associative + chain of three or more operands, perry evaluates every operand up front and only then performs the additions. The specification evaluates left to right with the additions interleaved — a + b + c is read a, read b, add, read c, add. When that first add runs user code through ToPrimitive (valueOf/toString), any mutation it makes to a later operand's source is invisible to perry, which already read it.
No crash, no diagnostic — a plain wrong number.
const O = { a: null, b: 1, c: 7 };
O.a = { valueOf() { O.c = 100; return 1; } };
console.log(O.a + O.b + O.c);
// node : 102 (a.valueOf() sets c=100, then 1 + 1 + 100)
// perry: 9 (a, b, c all read first: 1 + 1 + 7)Reproduced on pristine v0.5.1618 (train 239, b9ba951ff) and on train 252. Nothing lane-specific.
The split is exact — only a + chain of 3+ operands
Same object and the same mutating valueOf in every row ({ valueOf() { O.c = 100; return 1; } } bound to O.a):
| expression | node | perry | |
|---|---|---|---|
O.a + O.b + O.c |
102 |
9 |
wrong |
O.a + O.b + O.c + O.c |
202 |
16 |
wrong |
O.a + O.c (2 operands) |
8 |
8 |
correct |
O.a + (O.b + O.c) |
9 |
9 |
correct |
O.a - O.b - O.c |
-100 |
-100 |
correct |
O.a * O.b * O.c |
100 |
100 |
correct |
O.a + O.b * O.c |
8 |
8 |
correct |
O.a < O.b, then O.c |
false:100 |
false:100 |
correct |
O.a == O.b, then O.c |
true:100 |
true:100 |
correct |
`${O.a}${O.b}${O.c}` |
[object Object]17 |
[object Object]17 |
correct |
O.a + bump() + O.c (explicit call) |
102 |
102 |
correct |
So: left-associative +, three or more operands, and the user code reached implicitly. An explicit call in the same position is handled correctly, which is the tell — the chain fold does not know that + itself can call.
The mutation does not have to change the shape. All three of these are wrong, and the plain overwrite is the sharpest because nothing structural happens at all:
O.a = { valueOf() { O.c = 100; return 1; } }; // plain overwrite → perry 9, node 102
O.a = { valueOf() { delete O.c; return 1; } }; // delete → perry 9, node NaN
O.a = { valueOf() { Object.defineProperty(O, "c", { get: () => 50 }); return 1; } };
// accessor → perry 9, node 52toString reaches it too, and shows the stale read directly:
const O = { a: 1, b: null, c: 7 };
O.b = { toString() { delete O.c; return "2"; } };
console.log(O.a + O.b + O.c);
// node : "12undefined"
// perry: "127"Receiver form is irrelevant — module-level const, function-local const, class instance and array element all reproduce.
Relationship to #10775
Same family, different defect, and they do not overlap:
- correctness: a folded + chain coerces objects with ToString instead of ToPrimitive(default) — valueOf-only objects print [object Object] #10775 is the hint: a folded chain coerces with
ToStringwhere the spec wantsToPrimitive(default). Its text states explicitly that "Evaluation order and a throwingvalueOfboth still behave correctly." - This is the order: the operands are read before the additions that can mutate them. It reproduces with no string operand anywhere in the chain, so it is not the string-concat fold correctness: a folded + chain coerces objects with ToString instead of ToPrimitive(default) — valueOf-only objects print [object Object] #10775 analyses — it is whichever fold gathers a numeric
+chain's operands.
Fixing #10775 would not fix this, and vice versa.
Why it matters beyond the repro
o.x + o.y + o.z over one receiver is an extremely common shape, and the operands only have to be possibly non-primitive for the hazard to exist — which, without a representation fact, is every any/number|object field.
It is also a constraint on the object-model campaign's step 4b (region-scoped guards, #10884): a region that guards a receiver once and then loads slots by offset must not hoist a load above an operator that can call user code. The rule that makes 4b sound — load, then verify every loaded operand is primitive, and only then run the operators, with the single bail edge before any of them — is the same rule that fixes this, because verifying primitiveness first is exactly what proves the reordering unobservable.
Fixture
test-files/test_parity_region_guards.ts on feat/region-guards carries these as differential cases against node (A, A2, A3, A5 fail today; A4, B, B2, C, D, E pass).
Activity
proggeramlug commentedon Sep 21, 2026
Root cause — located
lower_guarded_numeric_add(crates/perry-codegen/src/expr/binary.rs:187), reached from the gate atbinary.rs:1134:lower_guarded_numeric_addcollects the whole+tree's leaves and evaluates all of them up front, then applies one shared tag test over them, then rebuilds the tree node-for-node in either arm:Fusing the tree into one diamond instead of one per node is deliberate and load-bearing — the doc comment at
:155-165records that per-node diamonds cost86 ms -> 119 mson a bench mini, because the outer add consumes a PHI that LLVM cannot prove is a canonical double. Associativity is preserved (rebuild_add_tree,:465). What is not preserved is the interleaving of evaluation with conversion.Why two leaves are correct and three are not
This is the whole boundary, and it is a spec detail rather than an implementation accident:
a + c— the spec evaluates both operands, thenToPrimitives both, then adds. Pre-evaluating both is exactly right. ✔(a + b) + c— the spec evaluatesa, evaluatesb,ToPrimitives both, adds, and only then evaluatesc. Socmust be read aftera's conversion has run. The fold reads it before. ✘That is why
O.a + O.cis correct,O.a + (O.b + O.c)is correct (the parenthesised node is a leaf, evaluated beforea's conversion — which is also what node does), andO.a + O.b + O.cis wrong.It also explains the rest of the split.
-and*never reach this path (ToNumber, notToPrimitive-with-a-string-outcome, and a different lowering); an explicit call in the same position is a leaf whose effects run duringwith_operands_rootedin source order, so it is correct; and the fall-through at:1137(lower_rooted_dynamic_binary) is spec-correct for 3+ leaves because it lowersleft— includingleft's own helper call and therefore its conversions — before it lowersright.The cold arm does not rescue it:
rebuild_add_tree(…, fast = false)rebuilds over the already-lowered leaf values, so even the slow, spec-+path adds the stalec. That is why the wrong answer is9rather than a crash.The minimal fix
Admit the 3+-leaf fold only when no leaf needs a test — i.e. every leaf is
expr_produces_canonical_raw_f64. If no leaf can be non-primitive, noToPrimitivecan run, no user code can run, and pre-evaluating every leaf is unobservable. Two leaves keep the fold unconditionally, per the spec order above.That is a change to
dynamic_add_tree_benefits_shared_guard(binary.rs:448) — it needsctxto ask the vouching question — and to its single call site at:1134.lower_guarded_numeric_addandrebuild_add_treeare not modified.Expected cost, stated up front
h += o.a + o.b + o.c + o.dis a five-leaf tree in which nothing is vouched (expr_produces_canonical_raw_f64deliberately declines aLocalGet,:180-182). It currently folds and after the fix will not, so it returns to per-node diamonds — the86 -> 119 msshape the fold exists to avoid. This will measurably slow thek/wfixtures and that is the correct trade; the numbers will be in the PR rather than tuned away.It is also exactly what step 4b stage 1 (#10884) buys back: a region verifies every loaded operand is a primitive number before any operator runs, which makes the leaves vouched and the fold admissible again — with the pre-evaluation licensed rather than assumed.
Affected versions / trigger
Reproduced on v0.5.1618 (train 239,
b9ba951ff) and train 252; the fold predates both. The trigger needs an object operand with a side-effectingvalueOf/toStringin a 3+ operand+chain — rare enough to explain how it survived, but it is the shapeDecimal/BigNumber-style wrapper libraries and test mocks use, and the mutation does not have to be structural: a plain overwrite of a later operand's field is enough.proggeramlug commentedon Sep 21, 2026
Scope note, so this is not read as "
a+b+cis broken": plain-number and plain-string chains were never wrong, and neither is any chain whose operands are all primitives. The fold pre-evaluates every leaf, and that is only observable when converting an earlier leaf runs user code — which requires an object operand with a side-effectingvalueOf/toString(orSymbol.toPrimitive) in a 3+ operand+chain.1 + 2 + 3,s1 + s2 + s3, ando.x + o.y + o.zover numeric fields all produce correct results today.That is also why it survived: the trigger is the wrapper-object pattern (
Decimal/BigNumber-style, test mocks, lazily-computed accessors), not everyday arithmetic.fix(#10904): the + fold may read a late leaf no conversion can change
fix(#10904): decide faithfulness inside the fold, which has two entries
test(#10904): pin the declared-number entry on a `this` receiver
test(#10904): pin the declared-number entry on a `this` receiver
proggeramlug commentedon Sep 22, 2026
For anyone auditing whether their code is affected: the same hazard has a second entry that this issue's title and repro do not cover, filed as #10937. This issue is the dynamic add path (
Anyoperands). #10937 is the declared-number path —a[0] + a[1] + a[2]over anumber[]prints 6 against node's 103, andthis.a + this.b + this.c + this.eovernumberfields prints 10 against node's 107, both on main841b605c9(v0.5.1632). #10921 fixes both, by moving the check to the top of the fold where every entry passes.fixtures: the step-4b region counterexamples, committed before the im…
fix(#10904): a + tree must not read a leaf after a conversion that pr…
fix(#10904): the + fold may read a late leaf no conversion can change
fix(#10904): decide faithfulness inside the fold, which has two entries
test(#10904): pin the declared-number entry on a `this` receiver
step 4b stage 1: read regions over a + tree, guarded ONCE (#10884)
step 4b stage 1: read regions over a + tree, guarded ONCE (#10884)
step 4b stage 1: read regions over a + tree, guarded ONCE (#10884)
step 4b stage 1: read regions over a + tree, guarded ONCE (#10884)