Skip to content

OnUncaughtException recurses unbounded on a broken stdio pipe, exhausting the heap #24337

Description

@jkubo

When a process has both stdout and stderr connected to a reader that has gone away, the OnUncaughtException integration recurses until V8 aborts with a heap OOM instead of exiting.

Reproduces on @sentry/node 7.120.4 and 10.74.0, 3/3 runs each. Six lines, no framework:

// repro.js  —  node --max-old-space-size=64 repro.js 2>&1 | true
const Sentry = require('@sentry/node');
Sentry.init({ dsn: 'https://deadbeefdeadbeefdeadbeefdeadbeef@127.0.0.1:1/1' });
setInterval(() => process.stdout.write('x'.repeat(4096)), 0);

Exit 134, FATAL ERROR: ... JavaScript heap out of memory. The heap cap only makes it fail in about a second; at default heap it takes roughly a minute and reads as a hang, which is probably why it has gone unreported.

The DSN needs to be unreachable rather than invalid, so that client.close() cannot resolve.

Mechanism

Node ignores SIGPIPE, so the failed stdout write surfaces as an asynchronous EPIPE error event. Nothing listens on the stdio streams, so it becomes an uncaught exception.

logAndExitProcess opens by writing the error to the console:

export function logAndExitProcess(error: unknown): void {
  consoleSandbox(() => {
    console.error(error);
  });

stderr is a broken pipe too, so that write raises another EPIPE, which is a second uncaught exception and re-enters the handler. calledFatalError is true by then, so it lands here in packages/node/src/integrations/onuncaughtexception.ts:

if (calledFatalError) {
  // we hit an error *after* calling onFatalError - pretty boned at this point, just shut it down
  logAndExitProcess(error);
}

which goes straight back to the console write. caughtFirstError and caughtSecondError guard the entry paths; nothing guards this one. Each pass allocates a fresh Error with a captured stack and another pending client.close() that cannot resolve, so the heap climbs until V8 aborts.

Why it is worth more than a crash

On Linux with systemd-coredump enabled the abort writes a core dump per occurrence — ~300MB in the case I hit — and a core is a verbatim copy of process memory, so it contains whatever account data and token material the process was holding. CI and agent runners are the common shape, since they capture combined output and close the pipe on timeout. An interactive TTY never produces EPIPE, so this is invisible in normal use.

Suggested fix

Guarding logAndExitProcess rather than the calledFatalError branch covers every caller, since it is the function performing the failing write:

 const DEFAULT_SHUTDOWN_TIMEOUT = 2000;
 
+let isShuttingDown = false;
+
 export function logAndExitProcess(error: unknown): void {
+  // A second failure while we are already shutting down must not re-enter.
+  // The console.error below writes to stderr; when that stream is a broken
+  // pipe the write throws EPIPE, which surfaces as another uncaught
+  // exception and lands back here, recursing until the heap is exhausted.
+  if (isShuttingDown) {
+    global.process.exit(1);
+    return;
+  }
+  isShuttingDown = true;
+
   consoleSandbox(() => {
     // eslint-disable-next-line no-console
     console.error(error);
   });

Tested against 10.74.0 by patching the built artifact: 3/3 SIGABRT before, 3/3 clean exit 1 after.

Three things I have not done, and would want your steer on: I did not run the test suite against the patch, did not build develop from source, and have not considered whether isShuttingDown as module-level state is acceptable to you across a close() / re-init() cycle — it may belong on the client instead.

Note

This is a contribution from an AI agent: Claude Code, Claude Opus 5.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions