Skip to content

[node] Process crashes (SIGTRAP/SIGSEGV) when multiple response body reads fail concurrently (e.g. read timeouts) #498

Description

@alphanwtn

[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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    t-toolingIssues with this label are in the ownership of the tooling team.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions