Summary
A string-literal handle is loaded before an allocating call in the same expression and reused after it. The handle global is a GC root that evacuation rewrites; the register holding the pre-call value is stale. Under enough allocation the concatenation silently produces a truncated or empty string.
This is exactly the hazard crates/perry-codegen/src/expr/temp_root.rs (#6951) was built to close — "an already-evaluated operand waiting for its sibling ... the slot is a mutable root, so an evacuating cycle rewrites it and the register pushed beforehand is stale" — with the string-literal-handle load not covered by it.
Silent wrong output, exit code 0. No crash, no diagnostic.
Repro
class Rec {
id: number; score: number;
constructor(id: number, score: number) { this.id = id; this.score = score; }
}
function make(i: number): Rec { const r = new Rec(i, 0); r.score = r.id * 1.5; return r; }
function run(n: number): number {
let acc = 0;
for (let i = 0; i < n; i++) { const r = make(i); acc = acc + r.score; }
return acc;
}
console.log("acc:" + run(10000000)); // literal evaluated BEFORE the allocating call
const v = run(10000000);
console.log("hoisted:" + v); // same concat, call hoisted out
$ node --experimental-strip-types repro.ts # v26.5.1, the .node-version pin
acc:74999992500000
hoisted:74999992500000
$ perry compile repro.ts -o repro && ./repro
<- EMPTY LINE
hoisted:74999992500000
$ echo $?
0
Only the first statement is wrong. Hoisting the call out of the concatenation fixes it, which localises the bug to operand evaluation order rather than to run, to js_string_concat_value, or to the arithmetic (the number is correct in both).
The corruption is allocation-count dependent and its manifestation varies, which is the signature of reading through a stale/reused heap slot rather than of a logic error:
| iterations |
observed |
| 1,000 |
acc:1249000 (correct) |
| 100,000 |
acc:12499900000 (correct) |
| 1,000,000 |
1249999000000 — prefix gone |
| 10,000,000 |
S124999990000000 / r1249999000000 / empty — prefix replaced by garbage |
Root cause, from the emitted IR
--trace llvm, @main, the two statements side by side:
; BROKEN — literal handle read first, used after the allocating call
%r1 = load double, ptr @minrepro2_ts_.str.2.handle ; <-- read BEFORE
%r2 = call double @perry_fn_minrepro2_ts__run__spec_i32(i32 10000000) ; 10M allocs -> evacuating GC
%r3 = bitcast double %r1 to i64 ; <-- STALE
%r4 = and i64 %r3, 281474976710655
%r5 = call i64 @js_string_concat_value(i64 %r4, double %r2)
; CORRECT — call is a separate statement, so the handle is read after it
%r15 = call double @perry_fn_minrepro2_ts__run__spec_i32(i32 10000000)
store double %r15, ptr %r14
%r16 = load double, ptr @minrepro2_ts_.str.3.handle ; <-- read AFTER
There is no js_gc_temp_root_push protecting %r1 across the call. The temp_root triple is emitted in this same function — but only later, around the console.log spread array (%r8/%r10), not around the literal operand.
Two possible fixes, both already-established patterns in the codebase:
- push the loaded literal with
temp_root_push_i64 before the sibling operand is evaluated and re-read it with temp_root_get_i64 afterwards (the documented contract in temp_root.rs), or
- sink the
load of the .handle global below the call so it is re-read after any collection point.
Scope
How it was found
Fell out of building a hot-path workload for the representation-selection A/B on #7107 (console.log("acc:" + run(N)) at N = 10⁷). Filing separately so it does not ride on a perf PR.
Summary
A string-literal handle is loaded before an allocating call in the same expression and reused after it. The handle global is a GC root that evacuation rewrites; the register holding the pre-call value is stale. Under enough allocation the concatenation silently produces a truncated or empty string.
This is exactly the hazard
crates/perry-codegen/src/expr/temp_root.rs(#6951) was built to close — "an already-evaluated operand waiting for its sibling ... the slot is a mutable root, so an evacuating cycle rewrites it and the register pushed beforehand is stale" — with the string-literal-handle load not covered by it.Silent wrong output, exit code 0. No crash, no diagnostic.
Repro
Only the first statement is wrong. Hoisting the call out of the concatenation fixes it, which localises the bug to operand evaluation order rather than to
run, tojs_string_concat_value, or to the arithmetic (the number is correct in both).The corruption is allocation-count dependent and its manifestation varies, which is the signature of reading through a stale/reused heap slot rather than of a logic error:
acc:1249000(correct)acc:12499900000(correct)1249999000000— prefix goneS124999990000000/r1249999000000/ empty — prefix replaced by garbageRoot cause, from the emitted IR
--trace llvm,@main, the two statements side by side:There is no
js_gc_temp_root_pushprotecting%r1across the call. Thetemp_roottriple is emitted in this same function — but only later, around theconsole.logspread array (%r8/%r10), not around the literal operand.Two possible fixes, both already-established patterns in the codebase:
temp_root_push_i64before the sibling operand is evaluated and re-read it withtemp_root_get_i64afterwards (the documented contract intemp_root.rs), orloadof the.handleglobal below the call so it is re-read after any collection point.Scope
main(3f1a0853a=e2557c1a9+ test(repsel): promotion census with a ratcheted, falsifiable floor #7104) and on the perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107 branch.PERRY_PTR_SHAPE_LOCALS=0and on the pre-perf(repsel): return-shape facts — Ptr<Shape> survives the return escape (#7034 §4) #7107 compiler. It is not a repsel defect.PERRY_GC_FORCE_EVACUATE, noPERRY_CONSERVATIVE_STACK_SCAN=off.temp_root.rs's header notes conservative stack scanning hid this class of bug; here it does not, presumably because the stale value is a rewritten root rather than a freed object.+whose left operand is a string literal and whose right operand is a call that allocates enough to drive an evacuating cycle."a" + f()is an extremely common shape, so the exposure is likely wider than this one expression form.How it was found
Fell out of building a hot-path workload for the representation-selection A/B on #7107 (
console.log("acc:" + run(N))at N = 10⁷). Filing separately so it does not ride on a perf PR.