Created
July 22, 2026 21:44
-
-
Save mattgodbolt/c4d066cf4bd3ea72f2d526f8e02a26d7 to your computer and use it in GitHub Desktop.
Repro for suspected chrome bug
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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