Skip to content

fix(node): prevent logAndExitProcess from recursing on a broken pipe - #24338

Open
jkubo wants to merge 1 commit into
getsentry:developfrom
jkubo:fix/logandexitprocess-epipe-recursion
Open

fix(node): prevent logAndExitProcess from recursing on a broken pipe#24338
jkubo wants to merge 1 commit into
getsentry:developfrom
jkubo:fix/logandexitprocess-epipe-recursion

Conversation

@jkubo

@jkubo jkubo commented Sep 11, 2026

Copy link
Copy Markdown

Fixes #24337.

logAndExitProcess writes the error to the console before shutting down. When stderr is a closed pipe that write raises EPIPE, which surfaces as another uncaught exception and re-enters the handler. calledFatalError is already set by then, so onuncaughtexception.ts routes straight back into logAndExitProcess:

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

and the cycle repeats. 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 with FATAL ERROR: ... JavaScript heap out of memory.

I guarded the function rather than the calledFatalError branch, since logAndExitProcess is what performs the failing write and this covers every caller.

Reproduction

// 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);

The DSN needs to be unreachable rather than invalid, so client.close() cannot resolve. The heap cap only makes it fail in about a second; at default heap it takes roughly a minute and reads as a hang.

build result
@sentry/node 10.74.0, stock exit 134 (SIGABRT, heap OOM), 3/3 runs
10.74.0 with this change applied to the built artifact exit 1 (clean), 3/3 runs

Also reproduces on 7.120.4, so this is not a recent regression.

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 stays invisible in normal use.

What I have not done

  • Not run the test suite against this change; I verified the behaviour with the repro above.
  • Verified against 10.74.0's build output rather than a source build of develop.
  • isShuttingDown is module-level state, so it persists across a close() / re-init() cycle in the same process. That seemed acceptable for a path that only runs on fatal shutdown, but if you would rather it lived on the client I am happy to move it.

Happy to add a regression test if you tell me where it belongs — packages/node/test/ has no existing coverage for this handler that I could find.

Note

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

logAndExitProcess writes the error to the console before shutting down.
When stderr is a closed pipe that write raises EPIPE, which surfaces as
another uncaught exception and re-enters the handler. calledFatalError is
already set by then, so onuncaughtexception routes straight back into
logAndExitProcess and the cycle repeats, allocating an Error with a
captured stack and a pending client.close() on every pass until V8
aborts with a heap OOM.

Guard the function itself rather than the calledFatalError branch, so
every caller is covered.

Fixes getsentry#24337

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jkubo
jkubo requested a review from a team as a code owner September 11, 2026 11:41
@jkubo
jkubo requested review from logaretm and stephanie-anderson and removed request for a team September 11, 2026 11:41

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e3f8993. Configure here.

global.process.exit(1);
return;
}
isShuttingDown = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix PR missing regression test

Medium Severity

This fix PR does not include a unit, integration, or E2E test for the logAndExitProcess broken-pipe recursion. I flagged this because it was mentioned in the review rules file, which requires a regression test on fix PRs. A test that re-enters the handler after a failed stderr write would lock in the isShuttingDown guard.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit e3f8993. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant