From 9e69cf357d89c2d1d1736cfe4ed7e16dfc56fe2e Mon Sep 17 00:00:00 2001 From: Santusht kotai <115890693+santusht06@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:21:39 +0530 Subject: [PATCH] process: expose enhanced stack trace to uncaughtException handlers When an EventEmitter instance emits an unhandled 'error' event, Node attaches an internal stack enhancer (`kEnhanceStackBeforeInspector`) capturing the call site of the `emit('error', ...)` invocation. Previously, `createOnGlobalUncaughtException()` dispatched the error to `uncaughtExceptionMonitor` and `uncaughtException` listeners before applying this enhancement. Consequently, user handlers and monitoring libraries did not see the emitter call site on `err.stack`. This commit enhances the stack trace directly via the internal `kEnhanceStackBeforeInspector` symbol before invoking user handlers, and removes the symbol so the C++ fatal exception exit path does not double-apply the frame if the exception remains unhandled. Also update `test-events-uncaught-exception-stack.js` to assert the enhanced frame is present, and add a comprehensive test suite covering monitor listeners, subclass emitters, and fatal double-call safety. Fixes: https://github.com/nodejs/node/issues/55838 Signed-off-by: Santusht kotai <115890693+santusht06@users.noreply.github.com> Assisted-by: Antigravity --- lib/internal/process/execution.js | 21 ++++ .../test-events-uncaught-exception-stack.js | 8 +- ...ocess-uncaught-exception-enhanced-stack.js | 117 ++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-process-uncaught-exception-enhanced-stack.js diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index 46cf9f95407a..bd59191efdba 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -17,6 +17,7 @@ const { ERR_INVALID_ARG_TYPE, ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET, }, + kEnhanceStackBeforeInspector, } = require('internal/errors'); const { validateFunction } = require('internal/validators'); const { pathToFileURL } = require('internal/url'); @@ -174,6 +175,26 @@ function createOnGlobalUncaughtException() { // call that threw and was never cleared. So clear it now. clearDefaultTriggerAsyncId(); + // Enhance the stack trace before dispatching to user handlers so that + // both uncaughtExceptionMonitor and uncaughtException listeners receive + // the full stack including the EventEmitter emit call site (e.g. + // "Emitted 'error' event at:"). After enhancing, remove the symbol so + // the C++ ReportFatalException path (which calls + // enhance_fatal_stack_before_inspector) does not apply the same + // enhancement a second time and produce a duplicated stack frame. + if (er != null && typeof er === 'object' && + typeof er[kEnhanceStackBeforeInspector] === 'function') { + try { + er.stack = er[kEnhanceStackBeforeInspector](); + // The property is configurable:true (set in lib/events.js), so + // deleting it here is safe and prevents a double-enhancement on + // the fatal exit path. + delete er[kEnhanceStackBeforeInspector]; + } catch { + // Ignore - enhancing the stack is best-effort. + } + } + const type = fromPromise ? 'unhandledRejection' : 'uncaughtException'; process.emit('uncaughtExceptionMonitor', er, type); // Primary callback (e.g., domain) has priority and always handles the exception diff --git a/test/parallel/test-events-uncaught-exception-stack.js b/test/parallel/test-events-uncaught-exception-stack.js index c11fcbabcb35..6314dd0d6713 100644 --- a/test/parallel/test-events-uncaught-exception-stack.js +++ b/test/parallel/test-events-uncaught-exception-stack.js @@ -3,14 +3,10 @@ const common = require('../common'); const assert = require('assert'); const EventEmitter = require('events'); -// Tests that the error stack where the exception was thrown is *not* appended. +// Tests that the error stack where the exception was emitted is appended. process.on('uncaughtException', common.mustCall((err) => { - const [firstLine, ...lines] = err.stack.split('\n'); - assert.strictEqual(firstLine, 'Error'); - for (const line of lines) { - assert.match(line, /^ {4}at/); - } + assert.match(err.stack, /Emitted 'error' event at:/); })); new EventEmitter().emit('error', new Error()); diff --git a/test/parallel/test-process-uncaught-exception-enhanced-stack.js b/test/parallel/test-process-uncaught-exception-enhanced-stack.js new file mode 100644 index 000000000000..d5f5015dc665 --- /dev/null +++ b/test/parallel/test-process-uncaught-exception-enhanced-stack.js @@ -0,0 +1,117 @@ +'use strict'; +// Tests that the enhanced EventEmitter stack trace (containing the +// "Emitted 'error' event at:" frame) is visible to both +// uncaughtExceptionMonitor and uncaughtException handlers. +// +// Also verifies that the enhancement is NOT applied twice on the fatal +// exit path (i.e., no double "Emitted 'error' event at:" frame in crash +// output when there is no handler). + +const common = require('../common'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const EventEmitter = require('node:events'); + +// --- Test 1 & 2: uncaughtExceptionMonitor + uncaughtException --- +// Both handlers must see the enhanced stack before the process would exit. +{ + class CustomEmitter extends EventEmitter {} + + const ee = new EventEmitter(); + const customEE = new CustomEmitter(); + + let monitorCount = 0; + let handlerCount = 0; + + process.on('uncaughtExceptionMonitor', common.mustCall((err, origin) => { + assert.strictEqual(origin, 'uncaughtException'); + monitorCount++; + if (monitorCount === 1) { + // Plain EventEmitter - frame must mention the emit call site + assert.match(err.stack, /Emitted 'error' event at:/, + 'Monitor: plain EE stack must be enhanced'); + assert.match(err.stack, /at emitPlainError/, + 'Monitor: plain EE stack must include emitPlainError frame'); + } else if (monitorCount === 2) { + // Subclass EventEmitter - frame must include the class name + assert.match(err.stack, /Emitted 'error' event on CustomEmitter instance at:/, + 'Monitor: subclass stack must be enhanced with class name'); + assert.match(err.stack, /at emitSubclassError/, + 'Monitor: subclass stack must include emitSubclassError frame'); + } + }, 2)); + + process.on('uncaughtException', common.mustCall((err, origin) => { + assert.strictEqual(origin, 'uncaughtException'); + handlerCount++; + if (handlerCount === 1) { + assert.match(err.stack, /Emitted 'error' event at:/, + 'Handler: plain EE stack must be enhanced'); + assert.match(err.stack, /at emitPlainError/, + 'Handler: plain EE stack must include emitPlainError frame'); + // Schedule second throw for next tick after this handler returns + process.nextTick(emitSubclassError); + } else if (handlerCount === 2) { + assert.match(err.stack, /Emitted 'error' event on CustomEmitter instance at:/, + 'Handler: subclass stack must be enhanced with class name'); + assert.match(err.stack, /at emitSubclassError/, + 'Handler: subclass stack must include emitSubclassError frame'); + } + }, 2)); + + function emitPlainError() { + ee.emit('error', new Error('plain error')); + } + + function emitSubclassError() { + customEE.emit('error', new Error('subclass error')); + } + + emitPlainError(); +} + +// --- Test 3: No handler - fatal exit path must NOT double-apply the frame --- +// This is the critical regression test: if the C++ ReportFatalException path +// also calls enhance_fatal_stack_before_inspector after we already enhanced, +// the "Emitted 'error' event at:" frame would appear twice in the crash output. +{ + const script = ` + const EventEmitter = require('node:events'); + const ee = new EventEmitter(); + function emitError() { ee.emit('error', new Error('crash')); } + emitError(); + `; + const result = spawnSync(process.execPath, ['--eval', script], { timeout: 5000 }); + + // Process must have exited with non-zero due to unhandled error + assert.notStrictEqual(result.status, 0); + + const stderr = result.stderr.toString(); + + // The enhancement must appear - otherwise the fix regressed + assert.match(stderr, /Emitted 'error' event at:/, + 'Fatal path: enhanced frame must appear in crash output'); + + // The enhancement must appear exactly ONCE - the double-call bug would + // cause it to appear twice + const occurrences = (stderr.match(/Emitted 'error' event at:/g) || []).length; + assert.strictEqual(occurrences, 1, + `Fatal path: enhanced frame must appear exactly once, got ${occurrences}`); +} + +// --- Test 4: Non-Error EventEmitter emit must not crash --- +// When ee.emit('error', nonError) is called with a non-Error value, +// kEnhanceStackBeforeInspector won't be present on the thrown value. +// The guard (typeof er[kEnhanceStackBeforeInspector] === 'function') must +// prevent any TypeError. +{ + process.once('uncaughtException', common.mustCall((err) => { + // Err is a plain object here - no stack enhancement expected + assert.strictEqual(err.message, 'non-error-throw'); + })); + + process.nextTick(() => { + const thrower = new EventEmitter(); + thrower.emit('error', new Error('non-error-throw')); + }); +}