[node] Process crashes (SIGTRAP/SIGSEGV) when multiple response body reads fail concurrently (e.g. read timeouts)
Summary
When two or more responses error during body consumption at roughly the same time (typically a read timeout while the server stalls mid-body), the Node.js process is killed by a native fatal error — no JS exception, no way to catch it. A single failing body read is fine; it's the concurrent error path that crashes.
The V8 fatal check is:
# Fatal error in , line 0
# Check failed: node->IsInUse().
with the crash inside v8impl::ThreadSafeFunction::~ThreadSafeFunction() → node::AsyncResource::~AsyncResource() → v8::internal::GlobalHandles::...::Release(), i.e. a V8 global handle being released twice (or after invalidation) during the teardown of the threadsafe function that backs the native→JS ReadableStream bridge created in ImpitResponse::get_inner_response.
Depending on timing/Node version the process dies with SIGTRAP, SIGSEGV or SIGBUS — consistent with a native race/use-after-free rather than a deterministic assert.
This is very likely the root cause of #344 (random crashes under high request throughput).
Environment
- impit (npm): reproduced on 0.13.1 and 0.14.2
- Node.js: reproduced on 22.13.1, 22.23.1 (SIGTRAP) and 24.18.0 (SIGBUS)
- OS: macOS arm64 (Darwin 25.3.0); originally observed daily in a production service under PM2 sustaining ~100 req/s through proxies (same two log lines always precede the crash)
- No proxies, no TLS, no remote hosts involved in the repro — plain local HTTP
Reproduction
Two files, no dependencies besides impit.
server.js — a local server that sends headers + a partial body, then stalls (which is what a flaky upstream/proxy does in real life):
const http = require("http");
http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": "100000" });
res.write("<html><head>" + "x".repeat(5000));
// never ends — client's read timeout will fire mid-body
}).listen(18923, () => console.log("stalling server on :18923"));
client.js — a handful of concurrent fetch() + text() calls with a timeout:
const { Impit } = require("impit");
const impit = new Impit({ browser: "chrome142", timeout: 1000 });
let errors = 0;
async function one() {
try {
const r = await impit.fetch("http://127.0.0.1:18923/");
await r.text(); // read timeout fires here, mid-stream
} catch (e) {
errors++; // this is the *expected* path: a catchable JS error
}
}
async function main() {
const start = Date.now();
const workers = Array.from({ length: 5 }, async () => {
while (Date.now() - start < 60_000) await one();
});
setInterval(() => console.log(`errors=${errors} (still alive)`), 5000).unref();
await Promise.all(workers);
console.log("survived", errors);
}
main();
Run:
node server.js &
node client.js ; echo "exit: $?"
Result: the process dies abnormally in ~90% of 30-second runs (10 crashes over 11 runs on my machine). The signal and the moment vary — classic native race behavior:
- exit 133 (SIGTRAP, the
Check failed: node->IsInUse() fatal below) — usually mid-run
- exit 139 (SIGSEGV) — sometimes mid-run, sometimes right at process exit after the loop finished
- exit 134 (SIGABRT) — once, with a Rust panic from inside std's RwLock internals (see below), which smells like memory corruption upstream of the panic:
thread '<unnamed>' panicked at library/std/src/sys/sync/rwlock/queue.rs:245:58:
called `Option::unwrap()` on a `None` value
fatal runtime error: failed to initiate panic, error 5, aborting
With length: 2 workers it usually survives a few dozen errors before dying; with length: 1 (fully sequential errors) it never crashes — which is what points at a race between concurrent erroring streams.
Every error is correctly surfaced to JS first (Error reading response stream: reqwest::Error { kind: Decode, source: reqwest::Error { kind: Body, source: TimedOut } }), so each individual failure is handled — the fatal crash happens out-of-band, shortly after.
Crash trace (Node 22.13.1, impit 0.14.2)
#
# Fatal error in , line 0
# Check failed: node->IsInUse().
#
----- Native stack trace -----
1: node::NodePlatform::GetStackTracePrinter() ...
2: V8_Fatal(char const*, ...)
3: v8::internal::GlobalHandles::NodeSpace<v8::internal::GlobalHandles::Node>::Release(...)
4: node::AsyncResource::~AsyncResource()
5: v8impl::(anonymous namespace)::ThreadSafeFunction::~ThreadSafeFunction()
6: node::Environment::CloseHandle<uv_handle_s, v8impl::...::ThreadSafeFunction::CloseHandlesAndMaybeDelete(bool)::...>(...)
7: uv_run
8: node::SpinEventLoopInternal(node::Environment*)
...
On an earlier run (impit 0.13.1) the same check failed from a different teardown path:
3: v8::internal::GlobalHandles::NodeSpace<...>::Release(...)
4: node::Buffer::(anonymous namespace)::CallbackInfo::~CallbackInfo()
5: node::CallbackQueue<...>::CallbackImpl<node::Buffer::...::CallbackInfo::OnBackingStoreFree()::...>::~CallbackImpl()
6: node::Environment::RunAndClearNativeImmediates(bool)
Both traces are the release of a global handle owned by the native stream bridge (ReadableStream::create_with_stream_bytes / its TSFN and external buffers) after the stream ended with an error.
Impact
Any long-running service doing sustained traffic through impit will eventually hit two concurrent read timeouts and get its whole process killed (uncatchable). In our production service this happens about once a day at ~100 req/s.
Notes
- Memory is stable on the success path (~726k successful
fetch()+text() calls, flat RSS) and on the sequential error path — this is purely the concurrent-error teardown race.
- Happy to provide more traces or test patched builds.
[node] Process crashes (SIGTRAP/SIGSEGV) when multiple response body reads fail concurrently (e.g. read timeouts)
Summary
When two or more responses error during body consumption at roughly the same time (typically a read timeout while the server stalls mid-body), the Node.js process is killed by a native fatal error — no JS exception, no way to catch it. A single failing body read is fine; it's the concurrent error path that crashes.
The V8 fatal check is:
with the crash inside
v8impl::ThreadSafeFunction::~ThreadSafeFunction()→node::AsyncResource::~AsyncResource()→v8::internal::GlobalHandles::...::Release(), i.e. a V8 global handle being released twice (or after invalidation) during the teardown of the threadsafe function that backs the native→JSReadableStreambridge created inImpitResponse::get_inner_response.Depending on timing/Node version the process dies with SIGTRAP, SIGSEGV or SIGBUS — consistent with a native race/use-after-free rather than a deterministic assert.
This is very likely the root cause of #344 (random crashes under high request throughput).
Environment
Reproduction
Two files, no dependencies besides
impit.server.js— a local server that sends headers + a partial body, then stalls (which is what a flaky upstream/proxy does in real life):client.js— a handful of concurrentfetch()+text()calls with a timeout:Run:
Result: the process dies abnormally in ~90% of 30-second runs (10 crashes over 11 runs on my machine). The signal and the moment vary — classic native race behavior:
Check failed: node->IsInUse()fatal below) — usually mid-runWith
length: 2workers it usually survives a few dozen errors before dying; withlength: 1(fully sequential errors) it never crashes — which is what points at a race between concurrent erroring streams.Every error is correctly surfaced to JS first (
Error reading response stream: reqwest::Error { kind: Decode, source: reqwest::Error { kind: Body, source: TimedOut } }), so each individual failure is handled — the fatal crash happens out-of-band, shortly after.Crash trace (Node 22.13.1, impit 0.14.2)
On an earlier run (impit 0.13.1) the same check failed from a different teardown path:
Both traces are the release of a global handle owned by the native stream bridge (
ReadableStream::create_with_stream_bytes/ its TSFN and external buffers) after the stream ended with an error.Impact
Any long-running service doing sustained traffic through impit will eventually hit two concurrent read timeouts and get its whole process killed (uncatchable). In our production service this happens about once a day at ~100 req/s.
Notes
fetch()+text()calls, flat RSS) and on the sequential error path — this is purely the concurrent-error teardown race.