Version
v24.11.0 (V8 13.6.233.10-node.28) and v26.9.0 (V8 14.6.202.34-node.32). Not reproduced on v22.23.1 or v20.20.1. Later 24.x releases were not tested.
Platform
Darwin 27.0.0 arm64 (macOS 27.0, Apple M4 Pro, 14 cores)
Subsystem
V8 (Maglev OSR)
What steps will reproduce the bug?
Run the script below many times, one process per run. It exits 1 and prints BUG when it catches the problem.
for i in $(seq 1 2000); do node maglev-osr-alias.mjs > /dev/null || echo FAIL; done | sort | uniq -c
The script is plain JavaScript with no dependencies. faceStats(s, face, off) loops over a triangle soup held in typed arrays, accumulates two doubles (area, maxOff), fills a Set and a Map, and returns { area, maxOff, verts, rim }. The script calls it eight times on different generated inputs, doing some allocation between calls, and keeps each returned object next to the numbers read from it immediately after the call. At the end it compares each object with what it returned.
maglev-osr-alias.mjs (105 lines)
// Node 24 (V8 13.6): objects returned by a function come out sharing the storage of
// their double-valued fields, so results of earlier calls change after later calls.
//
// Intermittent: about 1 run in 50 on Node 24.11.0 and 1 in 175 on Node 26.9.0 (macOS arm64).
// Run it in a loop:
//
// for i in $(seq 1 2000); do node maglev-osr-alias.mjs > /dev/null || echo FAIL; done | sort | uniq -c
//
// It does not fail with --no-maglev-osr.
//
// `faceStats` sums the area of the triangles of one face of a triangle soup and returns
// { area, maxOff, verts, rim }. The script calls it eight times on different inputs, keeps
// each returned object together with the numbers read from it right after the call, and
// at the end compares each object with what it returned.
class V3 {
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
clone() { return new V3(this.x, this.y, this.z); }
sub(v) { this.x -= v.x; this.y -= v.y; this.z -= v.z; return this; }
add(v) { this.x += v.x; this.y += v.y; this.z += v.z; return this; }
cross(v) { const ax = this.x, ay = this.y, az = this.z; this.x = ay * v.z - az * v.y; this.y = az * v.x - ax * v.z; this.z = ax * v.y - ay * v.x; return this; }
length() { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); }
divideScalar(s) { this.x /= s; this.y /= s; this.z /= s; return this; }
}
function faceStats(s, face, off) {
const id = s.ids.get(face.id);
const P = (i) => new V3(s.pos[3 * i], s.pos[3 * i + 1], s.pos[3 * i + 2]);
let area2 = 0, maxOff = 0;
const verts = new Set();
const use = new Map();
for (let t = 0; t < s.tris.length / 3; t++) {
if (s.faceOf[t] !== id) continue;
const idx = [s.tris[3 * t], s.tris[3 * t + 1], s.tris[3 * t + 2]];
const [a, b, c] = idx.map(P);
area2 += b.clone().sub(a).cross(c.clone().sub(a)).length() / 2;
maxOff = Math.max(maxOff, off(a.clone().add(b).add(c).divideScalar(3)));
for (let e = 0; e < 3; e++) {
verts.add(idx[e]);
const k = [idx[e], idx[(e + 1) % 3]].sort((x, y) => x - y).join("|");
use.set(k, (use.get(k) ?? 0) + 1);
}
}
const rim = new Set();
for (const [k, c] of use) if (c === 1) for (const i of k.split("|")) rim.add(Number(i));
return { area: area2, maxOff, verts, rim };
}
// ---- generated input: triangle soups on a cylinder of radius 5, each triangle tagged with a face label ----
let seed = 12345;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
function soup(nVerts, nTris, nFaces, label, labelTris) {
const pos = new Float32Array(3 * nVerts);
for (let i = 0; i < nVerts; i++) { const th = rnd() * 2 * Math.PI; pos[3 * i] = 5 * Math.cos(th); pos[3 * i + 1] = 5 * Math.sin(th); pos[3 * i + 2] = rnd() * 8; }
const tris = new Uint32Array(3 * nTris), faceOf = new Uint32Array(nTris);
for (let t = 0; t < nTris; t++) {
const a = Math.floor(rnd() * (nVerts - 2));
tris[3 * t] = a; tris[3 * t + 1] = a + 1; tris[3 * t + 2] = a + 2;
let other = 1 + Math.floor(rnd() * nFaces); if (other === label) other = (other % nFaces) + 1;
faceOf[t] = t < labelTris ? label : other;
}
const faces = Array.from({ length: nFaces }, (_, k) => ({ id: 'face-' + k + '-' + Math.floor(rnd() * 1e9) }));
return { s: { pos, tris, faceOf, ids: new Map(faces.map((f, k) => [f.id, k + 1])) }, faceId: faces[label - 1].id };
}
// [vertices, triangles, faces, label asked for, triangles carrying that label, ms of other work before the call]
const CALLS = [
[5112, 10220, 3, 1, 10082, 40],
[5206, 528, 4, 1, 338, 20],
[5206, 528, 4, 4, 52, 0],
[5206, 528, 4, 4, 52, 40],
[5206, 528, 4, 4, 52, 10],
[2664, 5324, 4, 2, 2592, 60],
[2766, 2994, 5, 2, 204, 20],
[2766, 2994, 5, 5, 58, 0],
];
// Other work between calls: allocation, some of it retained, so garbage collections run.
const ring = []; let sink = 0, w = 0;
function churn(ms) {
if (ms <= 0) return;
const end = performance.now() + ms;
while (performance.now() < end) {
for (let i = 0; i < 2000; i++) {
const o = { a: w * 0.5, b: [w, w + 1, w + 2], c: 'k' + (w % 977), d: { e: w } };
if (ring.length < 200000) ring.push(o); else ring[w % 200000] = o;
sink += o.b.length; w++;
}
}
}
const mkOff = (R) => (p) => Math.abs(Math.hypot(p.x, p.y) - R);
const out = [];
CALLS.forEach(([nv, nt, nf, label, labelTris, gap], ci) => {
churn(gap);
const { s, faceId } = soup(nv, nt, nf, label, labelTris);
const face = ci % 3 ? { id: faceId, kind: 'x' + ci, n: ci } : { id: faceId }; // a few object shapes, as real callers have
const r = faceStats(s, face, mkOff(5)); // a fresh closure per call
out.push({ r, area: r.area, maxOff: r.maxOff }); // remember what the call returned
});
let bad = 0;
out.forEach((o, i) => {
if (!Object.is(o.r.area, o.area) || !Object.is(o.r.maxOff, o.maxOff)) {
bad++;
console.log(`call ${i + 1}: returned area=${o.area} maxOff=${o.maxOff}; the same object now reads area=${o.r.area} maxOff=${o.r.maxOff}`);
}
});
console.log(bad ? 'BUG: results of earlier calls changed after later calls' : 'ok');
process.exit(bad ? 1 : 0);
How often does it reproduce? Is there a required condition?
Intermittent. Eight runs in parallel on the 14-core machine above, which was also busy with other work:
| runtime |
failed / runs |
| v24.11.0 |
58 / 3000 |
v24.11.0 --no-maglev-osr |
0 / 3000 |
v24.11.0 --no-concurrent-osr |
0 / 1500 |
v24.11.0 --single-threaded-gc |
2 / 1500 |
| v26.9.0 |
17 / 3000 |
v26.9.0 --no-maglev-osr |
0 / 3000 |
| v22.23.1 |
0 / 3000 |
| v20.20.1 |
0 / 1500 |
The timing of the work between the calls matters: with every gap set to 0 the script did not fail in 600 runs, and some other gap patterns did not fail either. The gaps in the script (40, 20, 0, 40, 10, 60, 20, 0 ms) are the ones that failed most often here; a different machine may need different ones.
What is the expected behavior? Why is that the expected behavior?
Every call returns a fresh object literal, so an object's area and maxOff must keep the values they had when the call returned. The script should always print ok.
What do you see instead?
Two or more of the returned objects share their double-valued fields. They are distinct objects (!==) with different verts and rim sets, but area and maxOff of the earlier ones now read as the values of the latest one:
call 4: returned area=906.4244995830119 maxOff=4.603054157215277; the same object now reads area=1227.004391521744 maxOff=4.335027103651656
call 5: returned area=993.2168554423932 maxOff=4.695433341606722; the same object now reads area=1227.004391521744 maxOff=4.335027103651656
call 6: returned area=46422.496206723445 maxOff=4.9146857121757845; the same object now reads area=1227.004391521744 maxOff=4.335027103651656
call 7: returned area=3754.819562707991 maxOff=4.836269298746963; the same object now reads area=1227.004391521744 maxOff=4.335027103651656
BUG: results of earlier calls changed after later calls
Recomputing with the same inputs gives the originally returned values, so the computation is right and the stored fields are what changes.
Additional information
Trace signature. 600 runs on v24.11.0 with --trace-opt --trace-deopt: 13 failed. Every failing run has two or three eager deopts of the same Maglev code object for faceStats, compiled for OSR, at the bytecode right after CreateObjectLiteral (the first DefineNamedOwn of the returned literal):
[bailout (kind: deopt-eager, reason: Insufficient type feedback for generic named access): begin. deoptimizing <JSFunction faceStats>, <Code MAGLEV>, opt id 32, bytecode offset 1390, ...]
[bailout (kind: deopt-eager, reason: Insufficient type feedback for generic named access): begin. deoptimizing <JSFunction faceStats>, <Code MAGLEV>, opt id 32, bytecode offset 1390, ...]
Of the 587 passing runs, 575 have no such deopt and 12 have exactly one. The number of objects sharing fields equals the number of these deopts: 9 runs had two deopts and one earlier object changed, 4 runs had three deopts and two earlier objects changed. So the objects that end up aliased are the ones allocated by that cached Maglev OSR code just before it deopts at the literal's first store; the interpreter then stores the doubles.
My reading, not verified in V8's source: those objects are created sharing one mutable HeapNumber box per double field (the boilerplate's, or one another's), and the interpreter's in-place double stores then write through to all of them.
Flags. --no-maglev-osr removes it on both versions. In the application where this was found, --no-turbofan, --no-osr-from-maglev, --no-concurrent-osr and --single-threaded-gc each also gave 0 failures in 400 runs against 6 in 400 by default; with the reduced script --single-threaded-gc still fails, so that one only shifts timing.
How it was found. A vitest suite (one forked process per test file) had two geometry tests fail intermittently with values wrong by a large factor. With the application's own code, one cold process per test file: v24.11.0 failed 27 of 2820 runs, 0 of 2680 with --no-maglev-osr, and v22.23.1 0 of 2552. The script above is a reduction of the test helper with generated data; it is not minimal.
Possibly related: #66126 (concurrent-compiler races on v24.x), #64841.
Version
v24.11.0 (V8 13.6.233.10-node.28) and v26.9.0 (V8 14.6.202.34-node.32). Not reproduced on v22.23.1 or v20.20.1. Later 24.x releases were not tested.
Platform
Subsystem
V8 (Maglev OSR)
What steps will reproduce the bug?
Run the script below many times, one process per run. It exits 1 and prints
BUGwhen it catches the problem.The script is plain JavaScript with no dependencies.
faceStats(s, face, off)loops over a triangle soup held in typed arrays, accumulates two doubles (area,maxOff), fills aSetand aMap, and returns{ area, maxOff, verts, rim }. The script calls it eight times on different generated inputs, doing some allocation between calls, and keeps each returned object next to the numbers read from it immediately after the call. At the end it compares each object with what it returned.maglev-osr-alias.mjs(105 lines)How often does it reproduce? Is there a required condition?
Intermittent. Eight runs in parallel on the 14-core machine above, which was also busy with other work:
--no-maglev-osr--no-concurrent-osr--single-threaded-gc--no-maglev-osrThe timing of the work between the calls matters: with every gap set to 0 the script did not fail in 600 runs, and some other gap patterns did not fail either. The gaps in the script (40, 20, 0, 40, 10, 60, 20, 0 ms) are the ones that failed most often here; a different machine may need different ones.
What is the expected behavior? Why is that the expected behavior?
Every call returns a fresh object literal, so an object's
areaandmaxOffmust keep the values they had when the call returned. The script should always printok.What do you see instead?
Two or more of the returned objects share their double-valued fields. They are distinct objects (
!==) with differentvertsandrimsets, butareaandmaxOffof the earlier ones now read as the values of the latest one:Recomputing with the same inputs gives the originally returned values, so the computation is right and the stored fields are what changes.
Additional information
Trace signature. 600 runs on v24.11.0 with
--trace-opt --trace-deopt: 13 failed. Every failing run has two or three eager deopts of the same Maglev code object forfaceStats, compiled for OSR, at the bytecode right afterCreateObjectLiteral(the firstDefineNamedOwnof the returned literal):Of the 587 passing runs, 575 have no such deopt and 12 have exactly one. The number of objects sharing fields equals the number of these deopts: 9 runs had two deopts and one earlier object changed, 4 runs had three deopts and two earlier objects changed. So the objects that end up aliased are the ones allocated by that cached Maglev OSR code just before it deopts at the literal's first store; the interpreter then stores the doubles.
My reading, not verified in V8's source: those objects are created sharing one mutable
HeapNumberbox per double field (the boilerplate's, or one another's), and the interpreter's in-place double stores then write through to all of them.Flags.
--no-maglev-osrremoves it on both versions. In the application where this was found,--no-turbofan,--no-osr-from-maglev,--no-concurrent-osrand--single-threaded-gceach also gave 0 failures in 400 runs against 6 in 400 by default; with the reduced script--single-threaded-gcstill fails, so that one only shifts timing.How it was found. A vitest suite (one forked process per test file) had two geometry tests fail intermittently with values wrong by a large factor. With the application's own code, one cold process per test file: v24.11.0 failed 27 of 2820 runs, 0 of 2680 with
--no-maglev-osr, and v22.23.1 0 of 2552. The script above is a reduction of the test helper with generated data; it is not minimal.Possibly related: #66126 (concurrent-compiler races on v24.x), #64841.