Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/internal/process/execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions test/parallel/test-events-uncaught-exception-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
117 changes: 117 additions & 0 deletions test/parallel/test-process-uncaught-exception-enhanced-stack.js
Original file line number Diff line number Diff line change
@@ -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'));
});
}
Loading