Intermittent fatal CHECK in compiler/heap-refs on v24.x — request to backport V8 14.6's JSHeapBroker thread-safety fixes
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 122k
- Forks
- 37.3k
- Avg merge
- 4d 2h
- Merged PRs (30d)
- 283
Description
Version
v24.11.1 (V8 13.6.233.10-node.28)
Platform
Linux x86_64, kernel 6.1.134-152.225.amzn2023.x86_64
libc: musl (Alpine-based container image)
cgroup v2, CPU quota = 12 cores, memory limit 24576 MiB, swap off
Subsystem
deps/v8 (compiler)
What steps will reproduce the bug?
I don't have a minimal reproduction and I want to be upfront about that, because
three separate things make this environment hard to instrument (details at the
bottom): release builds report line 0 with an empty file, ----- Native stack trace ----- is empty on musl, and coredumps are piped to a host-side
systemd-coredump the container cannot read.
The workload is a Vitest 5 run in forks pool with isolate: true: one
short-lived node child process per test file, 1579 children per run, 32
concurrent against a 12-core quota, ~740 s wall clock. Each child spends most of
its life loading and evaluating modules (transform 61%, import 27%, tests 5%),
so it is optimizing-compiler-heavy startup, repeated 1579 times. The children are
plain JS workloads — component/unit tests on jsdom. No node:http2, no network
server, no HTTP/2 client anywhere in them.
Occasionally one child dies on a signal. The rest of the run is green.
How often does it reproduce? Is there a required condition?
7 runs with at least one crash out of 87 runs (~8%), i.e. roughly 1 in 20000
child processes. One run produced three separate events.
CPU oversubscription appears to be required: I have never reproduced it on an
idle machine, and a dedicated fork-storm stress test (tens of thousands of bare
node -e '' spawns) never hit it — the child has to actually be compiling
JavaScript. I want to flag a measurement caveat, since it cost me a round: the
loadavg I print is sampled when the run starts, so it only reflects the host
baseline and not the load the run itself creates. Anyone trying to reproduce
should think in terms of total contention (host baseline + concurrent children
÷ available cores), not the number reported in my logs.
What is the expected behavior?
The child process should not die.
What do you see instead?
Three signatures so far, all in the same subsystem.
A. Check failed: (data_) != nullptr. with SIGTRAP:
#
# Fatal error in , line 0
# Check failed: (data_) != nullptr.
#
#
#
#FailureMessage Object: 0x7fa7fa0b27e0
----- Native stack trace -----
B. Check failed: IsJSFunction(). with SIGTRAP — same shape, different
assertion. This one is the informative one: it means data_ was not null but
the type check failed.
C. Plain SIGSEGV with no V8 output at all.
The parent reports Worker exited unexpectedly with signal <SIG>. SIGTRAP rather
than SIGABRT is consistent with --hard-abort (default true): V8_Fatal →
base::OS::Abort() → IMMEDIATE_CRASH() → int3 on x64 non-Darwin.
Additional information
What I'm asking for. Please consider backporting these to v24.x:
| CL | date | title | bug |
|---|---|---|---|
c3553509f |
2026-01-15 | [compiler] Thread-safe IsArrayOrObjectPrototype |
467311868 |
b1c2cda9b |
2026-01-16 | [compiler] Thread-safe MapRef::GetConstructor,GetBackPointer |
467311868 |
0bb186fd9 |
2026-09-03 | release-store the initial map into the prototype tuple (broker could observe a map whose Factory::InitializeMap stores are not yet visible) |
542923494 |
All three are already-accepted upstream fixes for unsynchronized reads in
JSHeapBroker, which is exactly where we crash. I'm not asking anyone to debug
my workload — I'm reporting that a supported LTS line ships a V8 that predates
this cluster of fixes, and that we appear to be hitting the class in production
CI at ~8% of runs.
Crash sites. Line numbers against 13.6.233.10:
- Signature A:
CHECK_NOT_NULL(data_)exists in exactly two non-DCHECKplaces,
both insrc/compiler/heap-refs.h— line 360 (private
OptionalRef(ObjectData*)) and line 386 (ObjectRef(ObjectData*, bool)).
Worth noting how this is reachable in release:OptionalRef::value()guards
only withDCHECK(has_value())and then constructsTRef(data_, false), so
unwrapping an emptyOptionalRefin release does not trip theDCHECK— it
falls through intoObjectRef'sCHECK. This signature is what "unwrapped an
empty ref" looks like in a release build. - Signature B:
CHECK(Is##Name())fromDEFINE_REF_CONSTRUCTOR
(heap-refs.h:514-520,check_typedefaults to true) or fromDEFINE_AS
(heap-refs.cc:975-980). NoteObjectRef::AsJSFunction()itself only has a
DCHECK(heap-refs.cc:1137-1142), so in release theCHECKthat fires is
the one in theJSFunctionRefconstructor it builds. A plain
.AsJSFunction()at the call site is enough to produce this. - The two are mutually exclusive:
CHECK_NOT_NULL(data_)runs unconditionally,
so a null pointer always dies at A and never reaches B's type check. B
therefore says: non-nullObjectData, wrong type.
ObjectData::IsJSFunction() (heap-refs.cc:962-971) resolves either to
i::IsJSFunction(*object()) against the live heap or to the cached
GetMapInstanceType(). Both bottom out in some map's instance type, which is
why 0bb186fd9 looks like the closest match of the three: its commit message
says the broker "can observe a map whose Factory::InitializeMap stores are not
visible yet", with TSAN reporting on value1 and on Map::bit_field2 read from
MapData's constructor.
Why I think the broker's own reads are the leading candidate. In
13.6.233.10:
// src/compiler/heap-refs.cc:1805
ObjectRef MapRef::GetConstructor(JSHeapBroker* broker) const {
// Immutable after initialization.
return MakeRefAssumeMemoryFence(broker, object()->GetConstructor());
}
Map::GetConstructor() walks constructor_or_back_pointer, and that one slot
holds either a JSFunction (constructor) or a Map (back pointer) depending on
what the main thread is doing to the transition tree. MakeRefAssumeMemoryFence
doesn't validate, so a background read landing mid-flip doesn't fail — it
produces a non-null ObjectData for an object of the other type and defers the
failure to a downstream CHECK. That is signature B's exact shape. b1c2cda9b
is effectively an acknowledgement of this: it changes the return type to
OptionalObjectRef, switches to constructor_or_back_pointer(kRelaxedLoad) +
TryMakeRef, and re-tests IsMap() on every hop of the back-pointer walk.
Version containment. Checked with
gh api repos/v8/v8/compare/<tag>...<sha> (behind, ahead_by=0 ⇒ contained):
| Node branch | V8 | has c3553509f / b1c2cda9b |
has 0bb186fd9 |
|---|---|---|---|
| v24.x | 13.6.233.17 | no | no |
| v25.x | 14.1.146.11 | no | no |
| v26.x / main | 14.6.202.34 | yes | no (not in 14.8.1 either) |
The first two land in V8 14.6 (14.5.1 doesn't contain them, 14.6.202.34
does). And staying on the 24.x line doesn't help: 13.6.233.10 → 13.6.233.17
contains only 5 substantive cherry-picks (two TurboFan alias-analysis fixes, a
Smi zero-extension fix, a wasm CanonicalEquality fix, a regexp
EscapeRegExpSource fix) — none of them touch heap-refs, the broker, or
handles.
What is ruled out.
- Memory pressure / container OOM: cgroup
memory.peak10844/24576 MiB (44%),
memory.eventsoom_kill=0, swap off,pids385 against a 37827 limit. A
container OOM would be SIGKILL and a V8 heap OOM would be SIGABRT. - Third-party native addons in the crashing process. This is the part I think
is worth the most to you, because it's what separates this from #64841. I
enumerated what each child actuallydlopen()s, by hookingprocess.dlopen
from a--requireshim (inherited by the forked children). Over a full run
(1579 children, one test file each) the children load at most two addons:
bufferutil(884 children) andbigint-buffer(18). The bundler's native
binding is loaded only in the parent — children receive transformed modules
over IPC, so it never enters them; I had that backwards for a while because the
readout I was printing came from the parent process. Of the six distinct test
files that have crashed so far, two load no native addon at all, and the
most recent crash happened in a run wherebufferutilandutf-8-validate
were blocked atrequire()time — so that child contained no third-party
native code whatsoever. For those crashes, "an addon corrupted the heap" isn't
available as an explanation. - musl's small default thread stack. I chased this and it does not apply, so
nobody else needs to: V8 never callspthread_attr_setstacksizeon Linux
(base/platform/platform-posix.cc, only Darwin/AIX get bumped) and
LocalIsolatederives a background thread's limit from
GetCurrentStackPosition() - v8_flags.stack_size * KB(~984 KB assumed), which
would be badly wrong against musl's 128 KiB default. But concurrent compilation
runs on Node's platform workers, andnode_platform.cccreates those via
uv_thread_create, which goes through libuv'suv__thread_stack_size()→
RLIMIT_STACK(or a 2 MB fallback). Verified empirically with V8's own
regress-crbug-388320179.jsinsidenode:24.11.1-alpine: a 2000-link
prototype chain passes, where a 128 KiB stack should have died at ~340. - Stale V8 code cache: the runner deletes
NODE_COMPILE_CACHEfor the
children and nothing sets it.
Relationship to #64841. Similar shape (intermittent crash on Linux,
JSHeapBroker involved, CPU contention required, --no-maglev making it go
away), and its conclusion was explicitly corrected to "the Maglev stacks are
where corrupted state is detected, not the source of the corruption", with the
real cause being invalid nghttp2 accesses. I took that seriously before filing,
and I'm deliberately not commenting there — that issue is now scoped to HTTP/2
and this workload doesn't touch it. The difference in evidence is the bullet
above: for at least two of our crashes there was no third-party native code in
the process at all. Node's own built-in native code (ada, zlib, openssl, …) of
course remains in scope, and I can't rule that out — I just can't turn it off to
test it either.
On backport difficulty — disclosed up front. b1c2cda9b changes a public
return type (ObjectRef → OptionalObjectRef) and updates four caller files,
which have moved a lot since 13.6. So this is not a mechanical cherry-pick, and
doc/contributing/maintaining/maintaining-V8.md anticipates exactly this case
("the patch may require extra effort to merge … For important issues, we may be
able to lean on the V8 team"). If the conclusion is "too invasive for an LTS V8",
that's a legitimate answer; in that case I'd suggest the class is at least worth
recording against the 24.x line, since the failure mode is a hard process death
with no diagnosable output.
Two observability problems, independent of the root cause. These are what
made this take several rounds, and they'd hold even if the cause turns out to be
entirely on my side:
# Fatal error in , line 0— the releaseV8_Fatalpath passes an empty file
and line 0, so the assertion text is the only usable signal. It happens to be
unique for both of my signatures; it would not be for a commonerCHECK.----- Native stack trace -----prints its header and then nothing on musl,
because Node's backtrace depends on glibc'sexecinfo.h. A reader cannot
distinguish "no frames available on this libc" from "the process died before
it could walk the stack". A one-line explanation instead of an empty section
would save people real time. (Relevant to the Alpine tier-2 discussion in
#62764: on this platform a fatal V8 error is currently undiagnosable from logs
alone.)
What I'm doing next on my side, in case it's useful: rolling out
--no-concurrent-recompilation for the child processes (one flag — it also gates
concurrent Maglev via maglev-concurrent-dispatcher.cc), both as mitigation and
as the only discriminating experiment I have left. A recurrence under that flag
would falsify the attribution above in a single run, and I'll report back either
way. A clean result is much weaker evidence: at an 8% per-run base rate it
takes ~28 consecutive clean runs to get to p≈0.1.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/compiler/heap-refs.h and src/compiler/heap-refs.cc, then compare the cited V8 changes c3553509f, b1c2cda9b, and 0bb186fd against the v24.x V8 version. Use the described Vitest 5 fork workload to observe the failure, if possible. Done means determining whether the fixes can be adapted to v24.x and documenting the resulting validation or why the backport is not feasible.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, javascript, linux, node.js
- Domain
- backend, compilers, operating-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100