nodejs / nodejs/node

Intermittent fatal CHECK in compiler/heap-refs on v24.x — request to backport V8 14.6's JSHeapBroker thread-safety fixes

未关闭
#66,126 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

主要语言
JavaScript
星标
122k
派生
37.3k
平均合并
4 天 2 小时
30 天内合并 PR
283

描述

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-DCHECK places,
    both in src/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 with DCHECK(has_value()) and then constructs TRef(data_, false), so
    unwrapping an empty OptionalRef in release does not trip the DCHECK — it
    falls through into ObjectRef's CHECK. This signature is what "unwrapped an
    empty ref" looks like in a release build.
  • Signature B: CHECK(Is##Name()) from DEFINE_REF_CONSTRUCTOR
    (heap-refs.h:514-520, check_type defaults to true) or from DEFINE_AS
    (heap-refs.cc:975-980). Note ObjectRef::AsJSFunction() itself only has a
    DCHECK (heap-refs.cc:1137-1142), so in release the CHECK that fires is
    the one in the JSFunctionRef constructor 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-null ObjectData, 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.peak 10844/24576 MiB (44%),
    memory.events oom_kill=0, swap off, pids 385 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 actually dlopen()s, by hooking process.dlopen
    from a --require shim (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) and bigint-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 where bufferutil and utf-8-validate
    were blocked at require() 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 calls pthread_attr_setstacksize on Linux
    (base/platform/platform-posix.cc, only Darwin/AIX get bumped) and
    LocalIsolate derives 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, and node_platform.cc creates those via
    uv_thread_create, which goes through libuv's uv__thread_stack_size()
    RLIMIT_STACK (or a 2 MB fallback). Verified empirically with V8's own
    regress-crbug-388320179.js inside node: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_CACHE for 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 (ObjectRefOptionalObjectRef) 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:

  1. # Fatal error in , line 0 — the release V8_Fatal path 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 commoner CHECK.
  2. ----- Native stack trace ----- prints its header and then nothing on musl,
    because Node's backtrace depends on glibc's execinfo.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.

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从 src/compiler/heap-refs.h 和 src/compiler/heap-refs.cc 开始,然后将所引用的 V8 更改 c3553509f、b1c2cda9b 和 0bb186fd 与 v24.x 的 V8 版本进行比较。如果可能,使用所述的 Vitest 5 fork 工作负载来观察故障。完成的标准是确定这些 fixes 是否可以适配到 v24.x,并记录所得的验证结果,或说明 backport 不可行的原因。

由索引模型根据 Issue 内容生成。

评估

技术栈
cpp, javascript, linux, node.js
领域
backend, compilers, operating-systems
Issue 类型
缺陷
难度
5/5
预计耗时
一周以上
活跃度
活跃
描述清晰度
基本清楚
新手友好度
25/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。