Repository navigation
Description
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 AFTERThere 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_i64before the sibling operand is evaluated and re-read it withtemp_root_get_i64afterwards (the documented contract intemp_root.rs), or - sink the
loadof the.handleglobal below the call so it is re-read after any collection point.
Scope
- Reproduces on
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. - Independent of representation selection: identical with
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. - Default GC settings — no
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. - Trigger is a binary
+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.
Activity
docs(changelog): #7114 fragment; record the measured shape A/B in the…
fix(gc): re-derive a string-literal operand below the collection point (
test(gc): scope the #7114 IR assertions to the function under test (#…
fix(codegen): make every GC root store dominate the collection points…
fix(codegen): make every GC root store dominate the collection points…
fix(codegen): close #7192's own residual root-store hole, and make th…
fix(codegen): root a call receiver and a computed-read base across th…
fix(codegen): root a call receiver and a computed-read base across th…
fix(codegen): root a call receiver and a computed-read base across th…
fix(codegen): root the callee, `this` and every argument of js_closur…
fix(codegen): root the callee, `this` and every argument of js_closur…
ci(gc): make the root-dominance gate able to fail, baseline it honest…
ci(gc): make the moving-GC stale-root witnesses able to fail (+ #7205) (
refactor(codegen): migrate instance_misc1 + logical_collections + map…
refactor(codegen): migrate instance_misc1 + logical_collections + map…
fix(codegen): materialise only a receiver that cannot be re-read (#10943
fix(codegen): materialise only a receiver that cannot be re-read (#10943
fix(codegen): materialise only a receiver that cannot be re-read (#10943
test(codegen): split the WTF-8 operand test per lowering; native asse…
test(codegen): split the WTF-8 operand test per lowering; native asse…