What happened
A store into a module-global Buffer is silently dropped when the stored value's
source index comes from a conditionally-assigned local, inside a function. The
destination buffer keeps its initial contents. No error, no warning, no crash —
the program runs to completion and prints a wrong answer.
Minimal reproduction
const W = 64, H = 64, N = W * H;
const src = Buffer.alloc(N), dst = Buffer.alloc(N);
for (let i = 0; i < N; i++) src[i] = (i * 7 + 13) & 0xff;
function wb(y: number, x: number): void {
const yy = y < 0 ? 0 : y > H - 1 ? H - 1 : y; // conditionally-assigned local
dst[y * W + x] = src[yy * W + x] & 0xff; // <-- this store never lands
}
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) wb(y, x);
let s = 0;
for (let i = 0; i < N; i++) s += dst[i];
console.log("sum=" + s);
node : sum=522240
perry : sum=0 <-- dst is entirely untouched
What you expected
perry to print sum=522240, matching Node.
Environment
- Perry: 0.5.1220 (latest release),
perry-linux-x86_64.tar.gz, sha256 verified
- Host: x86_64, Linux 6.17, Ubuntu; Node v22.23.1 as the oracle
- Command:
perry compile repro.ts -o repro && ./repro
- Not yet checked against
main (~0.5.1519) — I don't have a source build.
Bisect
Same program shape, varying only the line that computes yy:
yy computed as |
result |
const yy = y; |
correct |
const yy = y < 0 ? 0 : y > H-1 ? H-1 : y; |
dst all zeros |
let yy = y; if (yy<0) yy=0; if (yy>H-1) yy=H-1; |
dst all zeros |
So it is not ternary-specific — an if-statement form fails identically. The
trigger is a conditionally-assigned local flowing into the index of a read from
one module-global buffer, where the function also stores into a different one.
These all stay correct, so the individual ingredients are fine:
- the same function with
dst[i] = v and no conditional (m1–m5)
- the clamp expression evaluated on its own, outside a buffer index
Buffer reads/writes at top level rather than inside a function
- the same loop with
number[] instead of Buffer
Math.imul and the FNV hash used to check the result
Wider blast radius
The bug is not limited to the all-zeros case. In a 5×5 convolution I was writing
against benchmarks/honest_bench/workloads/3_image_convolution, the same shape
produced a partially wrong image rather than an empty one — checksum 2d3a7c7f
where Node and Rust both produce 2ba2e053. That form is harder to notice,
because the output looks plausible.
Why this may be a regression
benchmarks/honest_bench/REPORT.md records a closely related bug as fixed in
v0.5.30:
buf[i] = v on Buffer / Uint8Array was a silent no-op. The lowering for
Expr::Uint8ArraySet in crates/perry-codegen/src/expr.rs was
lower_expr(value) — it evaluated the RHS and threw it away.
The trigger here is narrower — the conditional on the read index seems to be
what matters — but the symptom is the same: the store is evaluated and discarded.
It may be worth checking whether the v0.5.30 fix covers this path.
Impact
Silent wrong answers are the worst failure mode for a compiler: the program exits
0 and the output looks reasonable. Anything doing image, audio, or buffer
processing with clamped or wrapped indices — an extremely common shape — can hit
this. A compile-time error or even a crash would be far preferable.
🤖 Generated with Claude Code
What happened
A store into a module-global
Bufferis silently dropped when the stored value'ssource index comes from a conditionally-assigned local, inside a function. The
destination buffer keeps its initial contents. No error, no warning, no crash —
the program runs to completion and prints a wrong answer.
Minimal reproduction
What you expected
perryto printsum=522240, matching Node.Environment
perry-linux-x86_64.tar.gz, sha256 verifiedperry compile repro.ts -o repro && ./repromain(~0.5.1519) — I don't have a source build.Bisect
Same program shape, varying only the line that computes
yy:yycomputed asconst yy = y;const yy = y < 0 ? 0 : y > H-1 ? H-1 : y;let yy = y; if (yy<0) yy=0; if (yy>H-1) yy=H-1;So it is not ternary-specific — an
if-statement form fails identically. Thetrigger is a conditionally-assigned local flowing into the index of a read from
one module-global buffer, where the function also stores into a different one.
These all stay correct, so the individual ingredients are fine:
dst[i] = vand no conditional (m1–m5)Bufferreads/writes at top level rather than inside a functionnumber[]instead ofBufferMath.imuland the FNV hash used to check the resultWider blast radius
The bug is not limited to the all-zeros case. In a 5×5 convolution I was writing
against
benchmarks/honest_bench/workloads/3_image_convolution, the same shapeproduced a partially wrong image rather than an empty one — checksum
2d3a7c7fwhere Node and Rust both produce
2ba2e053. That form is harder to notice,because the output looks plausible.
Why this may be a regression
benchmarks/honest_bench/REPORT.mdrecords a closely related bug as fixed inv0.5.30:
The trigger here is narrower — the conditional on the read index seems to be
what matters — but the symptom is the same: the store is evaluated and discarded.
It may be worth checking whether the v0.5.30 fix covers this path.
Impact
Silent wrong answers are the worst failure mode for a compiler: the program exits
0 and the output looks reasonable. Anything doing image, audio, or buffer
processing with clamped or wrapped indices — an extremely common shape — can hit
this. A compile-time error or even a crash would be far preferable.
🤖 Generated with Claude Code