Repository navigation
correctness: a 3+ operand + chain reads all operands before the adds — a mutating valueOf/toString sees a stale later operand #10904
Description
Activity
Root cause — located
lower_guarded_numeric_add(crates/perry-codegen/src/expr/binary.rs:187), reached from the gate atbinary.rs:1134:if dynamic_add_tree_benefits_shared_guard(expr) && !materialization_hazard { return lower_guarded_numeric_add(ctx, expr); } return lower_rooted_dynamic_binary(ctx, "js_dynamic_string_or_number_add", left, right, …);
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:let mut leaves = Vec::new(); add_tree_leaves(expr, &mut leaves); let needs_test: Vec<bool> = leaves.iter() .map(|leaf| !expr_produces_canonical_raw_f64(ctx, leaf)).collect(); with_operands_rooted(ctx, &leaves, |ctx, values| { … }) // ← every leaf lowered here
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.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.- added 4 commits that reference this issue
on Sep 21, 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.- added 9 commits that reference this issue
on Sep 22, 2026
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 + cis read a, read b, add, read c, add. When that first add runs user code throughToPrimitive(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.
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+ operandsSame object and the same mutating
valueOfin every row ({ valueOf() { O.c = 100; return 1; } }bound toO.a):O.a + O.b + O.c1029O.a + O.b + O.c + O.c20216O.a + O.c(2 operands)88O.a + (O.b + O.c)99O.a - O.b - O.c-100-100O.a * O.b * O.c100100O.a + O.b * O.c88O.a < O.b, thenO.cfalse:100false:100O.a == O.b, thenO.ctrue:100true:100`${O.a}${O.b}${O.c}`[object Object]17[object Object]17O.a + bump() + O.c(explicit call)102102So: 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:
toStringreaches it too, and shows the stale read directly:Receiver form is irrelevant — module-level
const, function-localconst,classinstance and array element all reproduce.Relationship to #10775
Same family, different defect, and they do not overlap:
ToStringwhere the spec wantsToPrimitive(default). Its text states explicitly that "Evaluation order and a throwingvalueOfboth still behave correctly."+chain's operands.Fixing #10775 would not fix this, and vice versa.
Why it matters beyond the repro
o.x + o.y + o.zover 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 everyany/number|objectfield.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.tsonfeat/region-guardscarries these as differential cases against node (A, A2, A3, A5 fail today; A4, B, B2, C, D, E pass).