-
-
Notifications
You must be signed in to change notification settings - Fork 34.9k
Description
Version
Node.js v25.4.0
Platform
Debian 12 Bookworm (Chromebook Crostini)
Subsystem
quic (src/quic/session.cc)
What steps will reproduce the bug?
import { connect } from "node:quic";
connect("139.162.123.134:443", {
sni: "nghttp2.org",
alpn: "h3-29",
endpoint: { address: "0.0.0.0:0" },
handshakeTimeout: 5000,
});
setInterval(() => {}, 1000).unref?.();Run the script with experimental QUIC enabled:
node --experimental-quic test.mjsObserve that the process crashes with SIGSEGV shortly after printing the “connect” line (no JS exception is raised).
Optional (to confirm the root cause with gdb):
gdb --args node --experimental-quic test.mjs
In gdb, set a temporary breakpoint at node::quic::Session::UpdateDataStats() (or the resolved address) and run:
info functions UpdateDataStats
tbreak *0xaf54e0 # replace with the address shown by the previous command
run
When the breakpoint hits, inspect this->impl_ (offset +0x88 on my build):
info registers rdi
x/gx $rdi+0x88
It shows 0x0 (NULL), and execution later crashes when UpdateDataStats() dereferences it.
How often does it reproduce? Is there a required condition?
The crash is deterministic in my environment.
It reproduces every time I run:
node --experimental-quic test.mjs
There is no need for repeated attempts; the process consistently crashes shortly after initiating the QUIC connection.
The crash occurs during early connection/handshake processing, specifically when Session::Application::SendPendingData() invokes Session::UpdateDataStats() while impl_ is still nullptr.
At this point, the required condition appears to be:
UpdateDataStats() being called before Session::impl_ has been initialized.
Further testing across different Node versions may help determine whether this is a regression or long-standing issue in the experimental QUIC implementation.
What is the expected behavior? Why is that the expected behavior?
Running node --experimental-quic test.mjs should either:
- Establish the QUIC session (or progress the handshake) and keep running, or
- Fail gracefully by rejecting/raising an error at the JS API boundary (e.g., emitting an 'error'/'close' event, rejecting the connect() promise if applicable, or otherwise surfacing a normal runtime error),
but it should not crash the Node.js process.
A native SIGSEGV is an unrecoverable failure that:
- Prevents user code from handling errors or performing cleanup,
- Breaks basic reliability expectations for a networking API (especially during common operations like connection/handshake),
- Is inconsistent with typical Node behavior where transport/handshake errors are surfaced as errors/events rather than terminating the process.
In this specific case, Session::UpdateDataStats() unconditionally dereferences internal state (impl_) that can be NULL in some connection states. The expected behavior is that the implementation should either (a) ensure impl_ is initialized before UpdateDataStats() is called, or (b) defensively guard against impl_ == nullptr and surface connection failure through the normal error path instead of crashing.
What do you see instead?
Running the repro script with experimental QUIC enabled results in a native crash (SIGSEGV) shortly after starting the connection. The process terminates without a JS-level exception or a normal error event.
Under gdb, the crash is in node::quic::Session::UpdateDataStats():
Thread 1 "MainThread" received signal SIGSEGV, Segmentation fault.
0x0000000000af5521 in node::quic::Session::UpdateDataStats() ()
Backtrace (representative):
#0 node::quic::Session::UpdateDataStats()
#1 node::quic::Session::Application::SendPendingData()
#2 node::quic::Endpoint::Connect(...)
Disassembly shows the faulting instruction dereferencing a NULL-derived pointer:
mov 0x28(%rbx), %rax
At a breakpoint in UpdateDataStats(), the internal pointer at this + 0x88 is NULL (likely impl_ or equivalent), which leads to the subsequent NULL dereference and crash:
(gdb) info registers rdi
rdi = <valid Session*>
(gdb) x/gx $rdi+0x88
0x...: 0x0000000000000000So instead of a recoverable error, Node terminates with a segmentation fault while updating QUIC stats.
Additional information
Additional investigation with gdb shows that inside
Session::UpdateDataStats(), this is valid, but
*(this + 0x88) is null at function entry.
This corresponds to impl_ in src/quic/session.cc.
The function then dereferences this pointer unconditionally
(auto& stats_ = impl_->stats_;), leading to a NULL
dereference and SIGSEGV.
The call stack shows that UpdateDataStats() is invoked
from Session::Application::SendPendingData() during
early connection processing.
This suggests either:
impl_is not yet initialized whenUpdateDataStats()is called, orimpl_has been reset before all pending send operations complete.
Adding a null guard for impl_ inside UpdateDataStats()
would prevent the crash, though the underlying lifecycle
ordering issue may still need investigation.