Skip to content

Instantly share code, notes, and snippets.

@mattgodbolt
Created July 22, 2026 21:44
Show Gist options
  • Select an option

  • Save mattgodbolt/c4d066cf4bd3ea72f2d526f8e02a26d7 to your computer and use it in GitHub Desktop.

Select an option

Save mattgodbolt/c4d066cf4bd3ea72f2d526f8e02a26d7 to your computer and use it in GitHub Desktop.
Repro for suspected chrome bug
<!doctype html>
<!--
Minimal repro for a suspected V8 optimizer bug in Chrome 150.
Pattern: a hot method fills a Float32Array; when full, it passes the array to
a callback that transfers its ArrayBuffer via MessagePort.postMessage
(detaching it), then immediately replaces it:
this.buffer = new Float32Array(bufferLength);
where bufferLength is a const captured from the buffer's length (512) at
method entry. After JIT warm-up, the replacement is allocated with length 0,
as if the optimized code re-read the (now detached) buffer's length instead
of using the captured constant. The fill loop then can't make progress and
spins forever.
Under correct JS semantics this program cannot hang: `remaining` strictly
decreases every loop iteration unless the buffer length reads as zero, and a
zero-length buffer can only appear via the miscompiled allocation.
The workload runs in a Worker; the page watches a heartbeat and reports the
wedge instead of freezing the tab. Keep the tab foregrounded while it runs.
Observed: wedges after ~5.5s on Chrome 150.0.7871.114 (Linux x86_64), with
281 healthy ticks / 9,878 transferred buffers completed first.
Controls — must run indefinitely without wedging:
?notransfer=1 postMessage without the transfer list
--js-flags="--no-turbofan --no-maglev" optimizers disabled
Firefox other engines unaffected
Original context: the sound chip emulation of jsbeeb (github.com/mattgodbolt/jsbeeb)
froze the page this way ~1 minute after startup on Chrome 150 (150.0.7871.114
and .182, Linux); unchanged code, worked on earlier Chrome versions.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>V8 detached-buffer-length minimal repro</title>
<style>
body {
font-family: monospace;
margin: 2em;
}
#banner {
display: none;
background: #c00;
color: white;
padding: 1em;
font-size: 1.2em;
white-space: pre-wrap;
}
#status {
margin-top: 1em;
white-space: pre;
}
</style>
</head>
<body>
<h1>V8 detached-buffer-length minimal repro</h1>
<div id="banner"></div>
<div id="status">starting…</div>
<script>
const workerSource = `
class Chip {
constructor(onBuffer) {
this._onBuffer = onBuffer;
this.buffer = new Float32Array(512);
this.position = 0;
this.phase = 0;
}
// Stand-in for sample generation: enough work to be worth optimizing.
generate(out, offset, length) {
for (let i = 0; i < length; ++i) {
out[i + offset] = Math.sin(this.phase++ * 0.01);
}
}
advance(samples) {
let remaining = samples | 0;
const bufferLength = this.buffer.length;
while (remaining > 0) {
const todo = Math.min(remaining, bufferLength - this.position);
this.generate(this.buffer, this.position, todo);
this.position += todo;
remaining -= todo;
if (this.position === bufferLength) {
this._onBuffer(this.buffer);
// BUG: after warm-up this allocates length 0, despite
// bufferLength being a const captured as 512 above.
this.buffer = new Float32Array(bufferLength);
this.position = 0;
}
}
}
}
const useTransfer = !self.name.includes("notransfer");
const channel = new MessageChannel();
channel.port2.onmessage = () => {}; // drain the far side
let buffersPosted = 0;
const chip = new Chip((buffer) => {
buffersPosted++;
if (useTransfer) {
channel.port1.postMessage(buffer, [buffer.buffer]);
} else {
channel.port1.postMessage(buffer);
}
});
// Roughly the original cadence: many small advances plus a larger
// catch-up per 16ms tick (18,000 samples/tick, ~2,100 x 512-sample
// buffers/sec at 60 ticks/sec).
let ticks = 0;
function tick() {
for (let i = 0; i < 20; ++i) chip.advance(500);
chip.advance(8000);
ticks++;
postMessage({
ticks,
buffersPosted,
bufferLength: chip.buffer.length,
detached: chip.buffer.buffer.detached,
});
setTimeout(tick, 16);
}
tick();
`;
const params = new URLSearchParams(location.search);
const mode = params.get("notransfer") ? "notransfer" : "transfer";
const worker = new Worker(URL.createObjectURL(new Blob([workerSource], { type: "text/javascript" })), {
name: mode,
});
const banner = document.getElementById("banner");
const status = document.getElementById("status");
const start = performance.now();
let lastBeat = performance.now();
let gotAnyReport = false;
let lastReport = { ticks: 0, buffersPosted: 0 };
worker.onmessage = (event) => {
lastBeat = performance.now();
gotAnyReport = true;
lastReport = event.data;
};
// A worker that fails to start is a harness problem, not the bug —
// report it distinctly so it can't masquerade as a repro.
worker.onerror = (event) => {
banner.style.display = "block";
banner.textContent = `WORKER ERROR (harness problem, NOT the bug): ${event.message}`;
};
setInterval(() => {
const now = performance.now();
if (now - lastBeat > 2000) {
banner.style.display = "block";
banner.textContent = gotAnyReport
? "WEDGED: worker stopped responding " +
`after ${((lastBeat - start) / 1000).toFixed(1)}s\n` +
`last report: ${JSON.stringify(lastReport)}\n` +
navigator.userAgent
: "Worker never reported (harness problem, NOT the bug) — try serving over http instead of file://";
} else {
// A genuinely wedged worker never resumes, so auto-clearing the
// banner cannot hide a real repro — it only clears transient
// stalls (suspend, background throttling, GC).
banner.style.display = "none";
status.textContent =
`mode: ${mode}\n` +
`elapsed: ${((now - start) / 1000).toFixed(1)}s\n` +
`ticks: ${lastReport.ticks}\n` +
`buffers posted: ${lastReport.buffersPosted}\n` +
`buffer: length=${lastReport.bufferLength} detached=${lastReport.detached}`;
}
}, 250);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment