Silent wrong answers: relational comparison with an object operand, after ~726 iterations, in the DEFAULT configuration
A hot relational comparison whose left operand is an object (e.g. an array) starts
returning false unconditionally after roughly 11,600 executions. No crash, no
diagnostic — the program just computes wrong booleans from then on.
Found while profiling iso_miss/interp for #8384 follow-up work. Not caused by any
recent PR: it reproduces on clean main at d1d992966.
Reproducer (self-checking, no expectations to argue about)
Each iteration computes the same 16 comparisons and packs them into a bitmask. A correct
engine returns an identical mask every iteration; this reports the first iteration whose
mask differs from iteration 0.
const vals: unknown[] = ["0", []];
const ops = ["<", ">", "<=", ">="];
const REPS = 4000;
let mask0 = -1; let firstDiffRep = -1; let diffMask = -1;
for (let r = 0; r < REPS; r++) {
let mask = 0; let bit = 0;
for (let i = 0; i < vals.length; i++)
for (let j = 0; j < vals.length; j++)
for (let k = 0; k < ops.length; k++) {
const a = vals[i] as never, b = vals[j] as never;
const v = k === 0 ? (a < b) : k === 1 ? (a > b) : k === 2 ? (a <= b) : (a >= b);
if (v) mask = mask | (1 << bit);
bit = bit + 1;
}
if (r === 0) mask0 = mask;
else if (mask !== mask0 && firstDiffRep < 0) { firstDiffRep = r; diffMask = mask; }
}
console.log("mask0=" + mask0 + " firstDiffRep=" + firstDiffRep + " diffMask=" + diffMask);
node 26.5.1 : mask0=50604 firstDiffRep=-1 diffMask=-1 <- stable for all 4000 reps
perry d1d9929: mask0=50604 firstDiffRep=726 diffMask=172 <- correct for 725 reps, then wrong
Which comparisons break
correct 50604 = 1100010110101100
after 172 = 0000000010101100
| bit |
comparison |
correct |
after |
| 8 |
[] < "0" |
true |
false |
| 10 |
[] <= "0" |
true |
false |
| 14 |
[] <= [] |
true |
false |
| 15 |
[] >= [] |
true |
false |
Every flipped bit has the object on the LEFT, and every flip is true -> false.
Comparisons with the object on the right are unaffected. That is the signature of the
left operand's NaN-boxed pointer being consumed as a raw double: a pointer payload reads
as NaN, and NaN makes all four relational operators false.
It is the generational GC, and it needs write barriers
| configuration |
result |
| default |
wrong at rep 726 |
PERRY_GEN_GC=0 |
correct, all 4000 reps |
PERRY_WRITE_BARRIERS=0 |
correct, all 4000 reps |
PERRY_GC_FORCE_EVACUATE=1 |
wrong at rep 726 (unchanged) |
Disabling either the generational collector or write barriers makes it vanish, so this
lives on the generational/remembered-set path — it is not a codegen tier-up. Note
PERRY_NO_AUTO_OPTIMIZE=1 was set throughout, so the auto-optimize reoptimizer is not
involved either.
The from-space instruments are clean
Both report no offender while the wrong answer still occurs:
[gc-fromspace-scan clean] objects=6397 words=39250 missing_rewrites=0 dangling=0 owners=0
[gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=11 bytes_protected=11534336
Per docs/src/internals/gc-rooting-invariant.md this pattern — reproducible, silent,
invisible to the from-space scan — points away from an unrooted register and toward a
cached/derived value or a missed remembered-set entry rather than a stale pointer the
scanner can see.
Why this matters more than the benchmark it came from
This is a silent data-corruption bug in the shipped default configuration. It produces
no crash and no diagnostic; a long-running program simply begins computing wrong booleans.
Any comparison-driven code — sorting, range checks, search — is exposed once the site is
hot enough, and object-vs-primitive relational comparison is ordinary JS.
The wider divergence it was found through: a 32-value x 4-operator matrix (4096 cases)
accumulated into a string diverges from Node on 218 cases, all with an array operand.
The identical matrix printed per-case diverges on zero — the bug only appears once the
site runs hot enough, which is why an ordinary conformance test would not catch it.
Reproducers: the snippet above, and the 4096-case matrix version.
Silent wrong answers: relational comparison with an object operand, after ~726 iterations, in the DEFAULT configuration
A hot relational comparison whose left operand is an object (e.g. an array) starts
returning
falseunconditionally after roughly 11,600 executions. No crash, nodiagnostic — the program just computes wrong booleans from then on.
Found while profiling
iso_miss/interpfor #8384 follow-up work. Not caused by anyrecent PR: it reproduces on clean
mainatd1d992966.Reproducer (self-checking, no expectations to argue about)
Each iteration computes the same 16 comparisons and packs them into a bitmask. A correct
engine returns an identical mask every iteration; this reports the first iteration whose
mask differs from iteration 0.
Which comparisons break
[] < "0"[] <= "0"[] <= [][] >= []Every flipped bit has the object on the LEFT, and every flip is
true -> false.Comparisons with the object on the right are unaffected. That is the signature of the
left operand's NaN-boxed pointer being consumed as a raw double: a pointer payload reads
as NaN, and NaN makes all four relational operators
false.It is the generational GC, and it needs write barriers
PERRY_GEN_GC=0PERRY_WRITE_BARRIERS=0PERRY_GC_FORCE_EVACUATE=1Disabling either the generational collector or write barriers makes it vanish, so this
lives on the generational/remembered-set path — it is not a codegen tier-up. Note
PERRY_NO_AUTO_OPTIMIZE=1was set throughout, so the auto-optimize reoptimizer is notinvolved either.
The from-space instruments are clean
Both report no offender while the wrong answer still occurs:
Per
docs/src/internals/gc-rooting-invariant.mdthis pattern — reproducible, silent,invisible to the from-space scan — points away from an unrooted register and toward a
cached/derived value or a missed remembered-set entry rather than a stale pointer the
scanner can see.
Why this matters more than the benchmark it came from
This is a silent data-corruption bug in the shipped default configuration. It produces
no crash and no diagnostic; a long-running program simply begins computing wrong booleans.
Any comparison-driven code — sorting, range checks, search — is exposed once the site is
hot enough, and object-vs-primitive relational comparison is ordinary JS.
The wider divergence it was found through: a 32-value x 4-operator matrix (4096 cases)
accumulated into a string diverges from Node on 218 cases, all with an array operand.
The identical matrix printed per-case diverges on zero — the bug only appears once the
site runs hot enough, which is why an ordinary conformance test would not catch it.
Reproducers: the snippet above, and the 4096-case matrix version.