From 855468d0f0461bc4be4712a860d606827bc732a9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 29 Dec 2025 22:43:00 +0100 Subject: [PATCH 1/3] repl: remove dependency on domain module Replace the domain-based error handling with AsyncLocalStorage and setUncaughtExceptionCaptureCallback. This removes the REPL's dependency on the deprecated domain module while preserving all existing behavior: - Synchronous errors during eval are caught and displayed - Async errors (setTimeout, promises, etc.) are caught via the uncaught exception capture callback - Top-level await errors are caught and displayed - The REPL continues operating after errors - Multiple REPL instances can coexist with errors routed correctly Changes: - Use AsyncLocalStorage to track which REPL instance owns an async context, replacing domain's automatic async tracking - Add setupExceptionCapture() to install setUncaughtExceptionCaptureCallback for catching async errors and routing them to the correct REPL - Extract error handling logic into REPLServer.prototype._handleError() - Wrap eval execution in replContext.run() for async context tracking - Update newListener protection to check AsyncLocalStorage context - Throw ERR_INVALID_ARG_VALUE if options.domain is passed PR-URL: https://github.com/nodejs/node/pull/61227 --- lib/repl.js | 344 ++++++++++-------- test/common/repl.js | 8 +- .../repl-tab-completion-nested-repls.js | 8 +- test/parallel/test-repl-domain.js | 49 --- .../test-repl-eval-error-after-close.js | 2 - test/parallel/test-repl-let-process.js | 2 +- test/parallel/test-repl-mode.js | 4 +- ...est-repl-multiple-instances-async-error.js | 69 ++++ .../parallel/test-repl-pretty-custom-stack.js | 2 - .../test-repl-pretty-stack-custom-writer.js | 5 +- test/parallel/test-repl-pretty-stack.js | 13 +- test/parallel/test-repl-preview-newlines.js | 4 +- test/parallel/test-repl-syntax-error-stack.js | 2 +- test/parallel/test-repl-tab-complete-crash.js | 2 +- test/parallel/test-repl-tab.js | 2 - test/parallel/test-repl-top-level-await.js | 2 +- .../test-repl-uncaught-exception-async.js | 17 +- ...st-repl-uncaught-exception-evalcallback.js | 25 +- test/parallel/test-repl-uncaught-exception.js | 15 +- test/parallel/test-repl-underscore.js | 2 - test/pummel/test-repl-paste-big-data.js | 2 +- 21 files changed, 299 insertions(+), 280 deletions(-) delete mode 100644 test/parallel/test-repl-domain.js create mode 100644 test/parallel/test-repl-multiple-instances-async-error.js diff --git a/lib/repl.js b/lib/repl.js index 5ad9e4fbb1506f..2eee3def4e6909 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -73,7 +73,6 @@ const { RegExpPrototypeExec, SafePromiseRace, SafeSet, - SafeWeakSet, StringPrototypeCharAt, StringPrototypeEndsWith, StringPrototypeIncludes, @@ -115,7 +114,7 @@ const { const { Console } = require('console'); const { shouldColorize } = require('internal/util/colors'); const CJSModule = require('internal/modules/cjs/loader').Module; -const domain = require('domain'); +const { AsyncLocalStorage } = require('async_hooks'); let debug = require('internal/util/debuglog').debuglog('repl', (fn) => { debug = fn; }); @@ -123,6 +122,7 @@ const { ErrorPrepareStackTrace, codes: { ERR_CANNOT_WATCH_SIGINT, + ERR_INVALID_ARG_VALUE, ERR_INVALID_REPL_EVAL_CONFIG, ERR_INVALID_REPL_INPUT, ERR_MISSING_ARGS, @@ -178,7 +178,46 @@ const { let processTopLevelAwait; const parentModule = module; -const domainSet = new SafeWeakSet(); + +// AsyncLocalStorage to track which REPL instance owns the current async context +// This replaces the domain-based tracking for error handling +const replContext = new AsyncLocalStorage(); +let exceptionCaptureSetup = false; + +/** + * Sets up the uncaught exception capture callback to route errors + * to the appropriate REPL instance. This replaces domain-based error handling. + * @returns {boolean} true if setup succeeded, false if capture callback already in use + */ +function setupExceptionCapture() { + if (exceptionCaptureSetup) return true; + + if (process.hasUncaughtExceptionCaptureCallback()) { + return false; + } + + process.setUncaughtExceptionCaptureCallback((err) => { + const store = replContext.getStore(); + if (store?.replServer && !store.replServer.closed) { + store.replServer._handleError(err); + return; + } + // No active REPL context - re-emit for normal process handling + process.setUncaughtExceptionCaptureCallback(null); + exceptionCaptureSetup = false; + try { + if (!process.emit('uncaughtException', err, 'uncaughtException')) { + // No handler, rethrow to exit process + throw err; + } + } finally { + setupExceptionCapture(); + } + }); + + exceptionCaptureSetup = true; + return true; +} const kBufferedCommandSymbol = Symbol('bufferedCommand'); const kLoadingSymbol = Symbol('loading'); @@ -313,7 +352,13 @@ class REPLServer extends Interface { this.allowBlockingCompletions = !!options.allowBlockingCompletions; this.useColors = !!options.useColors; - this._domain = options.domain || domain.create(); + this._isStandalone = !!options[kStandaloneREPL]; + + if (options.domain !== undefined) { + throw new ERR_INVALID_ARG_VALUE('options.domain', options.domain, + 'is no longer supported'); + } + this.useGlobal = !!useGlobal; this.ignoreUndefined = !!ignoreUndefined; this.replMode = replMode || module.exports.REPL_MODE_SLOPPY; @@ -338,26 +383,27 @@ class REPLServer extends Interface { // from inside the REPL. This is useful for anyone working on the REPL. module.exports.repl = this; } else if (!addedNewListener) { - // Add this listener only once and use a WeakSet that contains the REPLs - // domains. Otherwise we'd have to add a single listener to each REPL - // instance and that could trigger the `MaxListenersExceededWarning`. + // Add this listener only once. Otherwise we'd have to add a single + // listener to each REPL instance and that could trigger the + // `MaxListenersExceededWarning`. process.prependListener('newListener', (event, listener) => { - if (event === 'uncaughtException' && - process.domain && - listener.name !== 'domainUncaughtExceptionClear' && - domainSet.has(process.domain)) { - // Throw an error so that the event will not be added and the current - // domain takes over. That way the user is notified about the error - // and the current code evaluation is stopped, just as any other code - // that contains an error. - throw new ERR_INVALID_REPL_INPUT( - 'Listeners for `uncaughtException` cannot be used in the REPL'); + if (event === 'uncaughtException') { + const store = replContext.getStore(); + if (store?.replServer) { + // Throw an error so that the event will not be added and the + // current REPL handles it. That way the user is notified about + // the error and the current code evaluation is stopped, just as + // any other code that contains an error. + throw new ERR_INVALID_REPL_INPUT( + 'Listeners for `uncaughtException` cannot be used in the REPL'); + } } }); addedNewListener = true; } - domainSet.add(this._domain); + // Set up exception capture for async error handling + setupExceptionCapture(); const savedRegExMatches = ['', '', '', '', '', '', '', '', '', '']; const sep = '\u0000\u0000\u0000'; @@ -578,13 +624,10 @@ class REPLServer extends Interface { } } catch (e) { err = e; - - if (process.domain) { - debug('not recoverable, send to domain'); - process.domain.emit('error', err); - process.domain.exit(); - return; - } + // Handle non-recoverable errors directly + debug('not recoverable, handle error'); + self._handleError(err); + return; } if (awaitPromise && !err) { @@ -610,13 +653,9 @@ class REPLServer extends Interface { const result = (await promise)?.value; finishExecution(null, result); } catch (err) { - if (err && process.domain) { - debug('not recoverable, send to domain'); - process.domain.emit('error', err); - process.domain.exit(); - return; - } - finishExecution(err); + // Handle non-recoverable async errors directly + debug('not recoverable, handle error'); + self._handleError(err); } finally { // Remove prioritized SIGINT listener if it was not called. prioritizedSigintQueue.delete(sigintListener); @@ -631,124 +670,16 @@ class REPLServer extends Interface { } } - self.eval = self._domain.bind(eval_); - - self._domain.on('error', function debugDomainError(e) { - debug('domain error'); - let errStack = ''; - - if (typeof e === 'object' && e !== null) { - overrideStackTrace.set(e, (error, stackFrames) => { - let frames; - if (typeof stackFrames === 'object') { - // Search from the bottom of the call stack to - // find the first frame with a null function name - const idx = ArrayPrototypeFindLastIndex( - stackFrames, - (frame) => frame.getFunctionName() === null, - ); - // If found, get rid of it and everything below it - frames = ArrayPrototypeSlice(stackFrames, 0, idx); - } else { - frames = stackFrames; - } - // FIXME(devsnek): this is inconsistent with the checks - // that the real prepareStackTrace dispatch uses in - // lib/internal/errors.js. - if (typeof MainContextError.prepareStackTrace === 'function') { - return MainContextError.prepareStackTrace(error, frames); - } - return ErrorPrepareStackTrace(error, frames); - }); - decorateErrorStack(e); - - if (e.domainThrown) { - delete e.domain; - delete e.domainThrown; - } - - if (isError(e)) { - if (e.stack) { - if (e.name === 'SyntaxError') { - // Remove stack trace. - e.stack = SideEffectFreeRegExpPrototypeSymbolReplace( - /^\s+at\s.*\n?/gm, - SideEffectFreeRegExpPrototypeSymbolReplace(/^REPL\d+:\d+\r?\n/, e.stack, ''), - ''); - const importErrorStr = 'Cannot use import statement outside a ' + - 'module'; - if (StringPrototypeIncludes(e.message, importErrorStr)) { - e.message = 'Cannot use import statement inside the Node.js ' + - 'REPL, alternatively use dynamic import: ' + toDynamicImport(ArrayPrototypeAt(self.lines, -1)); - e.stack = SideEffectFreeRegExpPrototypeSymbolReplace( - /SyntaxError:.*\n/, - e.stack, - `SyntaxError: ${e.message}\n`); - } - } else if (self.replMode === module.exports.REPL_MODE_STRICT) { - e.stack = SideEffectFreeRegExpPrototypeSymbolReplace( - /(\s+at\s+REPL\d+:)(\d+)/, - e.stack, - (_, pre, line) => pre + (line - 1), - ); - } - } - errStack = self.writer(e); - - // Remove one line error braces to keep the old style in place. - if (errStack[0] === '[' && errStack[errStack.length - 1] === ']') { - errStack = StringPrototypeSlice(errStack, 1, -1); - } - } - } - - if (!self.underscoreErrAssigned) { - self.lastError = e; - } - - if (options[kStandaloneREPL] && - process.listenerCount('uncaughtException') !== 0) { - process.nextTick(() => { - process.emit('uncaughtException', e); - self.clearBufferedCommand(); - self.lines.level = []; - if (!self.closed) { - self.displayPrompt(); - } - }); - } else { - if (errStack === '') { - errStack = self.writer(e); - } - const lines = SideEffectFreeRegExpPrototypeSymbolSplit(/(?<=\n)/, errStack); - let matched = false; - - errStack = ''; - ArrayPrototypeForEach(lines, (line) => { - if (!matched && - RegExpPrototypeExec(/^\[?([A-Z][a-z0-9_]*)*Error/, line) !== null) { - errStack += writer.options.breakLength >= line.length ? - `Uncaught ${line}` : - `Uncaught:\n${line}`; - matched = true; - } else { - errStack += line; - } - }); - if (!matched) { - const ln = lines.length === 1 ? ' ' : ':\n'; - errStack = `Uncaught${ln}${errStack}`; - } - // Normalize line endings. - errStack += StringPrototypeEndsWith(errStack, '\n') ? '' : '\n'; - self.output.write(errStack); - self.clearBufferedCommand(); - self.lines.level = []; - if (!self.closed) { - self.displayPrompt(); - } - } - }); + // Wrap eval to run within the REPL's async context for error tracking. + // The function names are needed for stack trace filtering - they must not + // be anonymous, but we can't use 'eval' as a name since it's reserved. + const originalEval = eval_; + // eslint-disable-next-line func-name-matching + self.eval = function REPLEval(code, context, file, cb) { + replContext.run({ replServer: self }, function REPLEvalInContext() { + originalEval(code, context, file, cb); + }); + }; self.clearBufferedCommand(); @@ -912,7 +843,7 @@ class REPLServer extends Interface { } if (e) { - self._domain.emit('error', e.err || e); + self._handleError(e.err || e); self[kLastCommandErrored] = true; } @@ -1038,6 +969,117 @@ class REPLServer extends Interface { clearBufferedCommand() { this[kBufferedCommandSymbol] = ''; } + _handleError(e) { + debug('handle error'); + let errStack = ''; + + if (typeof e === 'object' && e !== null) { + overrideStackTrace.set(e, (error, stackFrames) => { + let frames; + if (typeof stackFrames === 'object') { + // Search from the bottom of the call stack to + // find the first frame with a null function name + const idx = ArrayPrototypeFindLastIndex( + stackFrames, + (frame) => frame.getFunctionName() === null, + ); + // If found, get rid of it and everything below it + frames = ArrayPrototypeSlice(stackFrames, 0, idx); + } else { + frames = stackFrames; + } + // FIXME(devsnek): this is inconsistent with the checks + // that the real prepareStackTrace dispatch uses in + // lib/internal/errors.js. + if (typeof MainContextError.prepareStackTrace === 'function') { + return MainContextError.prepareStackTrace(error, frames); + } + return ErrorPrepareStackTrace(error, frames); + }); + decorateErrorStack(e); + + if (isError(e)) { + if (e.stack) { + if (e.name === 'SyntaxError') { + // Remove stack trace. + e.stack = SideEffectFreeRegExpPrototypeSymbolReplace( + /^\s+at\s.*\n?/gm, + SideEffectFreeRegExpPrototypeSymbolReplace(/^REPL\d+:\d+\r?\n/, e.stack, ''), + ''); + const importErrorStr = 'Cannot use import statement outside a ' + + 'module'; + if (StringPrototypeIncludes(e.message, importErrorStr)) { + e.message = 'Cannot use import statement inside the Node.js ' + + 'REPL, alternatively use dynamic import: ' + toDynamicImport(ArrayPrototypeAt(this.lines, -1)); + e.stack = SideEffectFreeRegExpPrototypeSymbolReplace( + /SyntaxError:.*\n/, + e.stack, + `SyntaxError: ${e.message}\n`); + } + } else if (this.replMode === module.exports.REPL_MODE_STRICT) { + e.stack = SideEffectFreeRegExpPrototypeSymbolReplace( + /(\s+at\s+REPL\d+:)(\d+)/, + e.stack, + (_, pre, line) => pre + (line - 1), + ); + } + } + errStack = this.writer(e); + + // Remove one line error braces to keep the old style in place. + if (errStack[0] === '[' && errStack[errStack.length - 1] === ']') { + errStack = StringPrototypeSlice(errStack, 1, -1); + } + } + } + + if (!this.underscoreErrAssigned) { + this.lastError = e; + } + + if (this._isStandalone && + process.listenerCount('uncaughtException') !== 0) { + process.nextTick(() => { + process.emit('uncaughtException', e); + this.clearBufferedCommand(); + this.lines.level = []; + if (!this.closed) { + this.displayPrompt(); + } + }); + } else { + if (errStack === '') { + errStack = this.writer(e); + } + const lines = SideEffectFreeRegExpPrototypeSymbolSplit(/(?<=\n)/, errStack); + let matched = false; + + errStack = ''; + ArrayPrototypeForEach(lines, (line) => { + if (!matched && + RegExpPrototypeExec(/^\[?([A-Z][a-z0-9_]*)*Error/, line) !== null) { + errStack += writer.options.breakLength >= line.length ? + `Uncaught ${line}` : + `Uncaught:\n${line}`; + matched = true; + } else { + errStack += line; + } + }); + if (!matched) { + const ln = lines.length === 1 ? ' ' : ':\n'; + errStack = `Uncaught${ln}${errStack}`; + } + // Normalize line endings. + errStack += StringPrototypeEndsWith(errStack, '\n') ? '' : '\n'; + this.output.write(errStack); + this.clearBufferedCommand(); + this.lines.level = []; + if (!this.closed) { + this.displayPrompt(); + } + } + } close() { if (this.terminal && this.historyManager.isFlushing && !this._closingOnFlush) { this._closingOnFlush = true; diff --git a/test/common/repl.js b/test/common/repl.js index 223050c76e1df0..6ce4d993a4c456 100644 --- a/test/common/repl.js +++ b/test/common/repl.js @@ -2,9 +2,8 @@ const ArrayStream = require('../common/arraystream'); const repl = require('node:repl'); -const assert = require('node:assert'); -function startNewREPLServer(replOpts = {}, testingOpts = {}) { +function startNewREPLServer(replOpts = {}) { const input = new ArrayStream(); const output = new ArrayStream(); @@ -20,11 +19,6 @@ function startNewREPLServer(replOpts = {}, testingOpts = {}) { ...replOpts, }); - if (!testingOpts.disableDomainErrorAssert) { - // Some errors are passed to the domain, but do not callback - replServer._domain.on('error', assert.ifError); - } - return { replServer, input, output }; } diff --git a/test/fixtures/repl-tab-completion-nested-repls.js b/test/fixtures/repl-tab-completion-nested-repls.js index 1d2b154f2b3341..79677491eca55f 100644 --- a/test/fixtures/repl-tab-completion-nested-repls.js +++ b/test/fixtures/repl-tab-completion-nested-repls.js @@ -1,6 +1,5 @@ // Tab completion sometimes uses a separate REPL instance under the hood. -// That REPL instance has its own domain. Make sure domain errors trickle back -// up to the main REPL. +// Make sure errors in completion callbacks are properly thrown. // // Ref: https://github.com/nodejs/node/issues/21586 @@ -31,11 +30,6 @@ const repl = require('repl'); const putIn = new ArrayStream(); const testMe = repl.start('', putIn); -// Some errors are passed to the domain, but do not callback. -testMe._domain.on('error', function(err) { - throw err; -}); - // Nesting of structures causes REPL to use a nested REPL for completion. putIn.run([ 'var top = function() {', diff --git a/test/parallel/test-repl-domain.js b/test/parallel/test-repl-domain.js deleted file mode 100644 index b7c8d95dd26c0e..00000000000000 --- a/test/parallel/test-repl-domain.js +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -'use strict'; -require('../common'); -const { startNewREPLServer } = require('../common/repl'); -const ArrayStream = require('../common/arraystream'); - -const stream = new ArrayStream(); - -startNewREPLServer({ - input: stream, - output: stream, - terminal: false, -}); - -stream.write = function(data) { - // Don't use assert for this because the domain might catch it, and - // give a false negative. Don't throw, just print and exit. - if (data === 'OK\n') { - console.log('ok'); - } else { - console.error(data); - process.exit(1); - } -}; - -stream.run([ - 'require("domain").create().on("error", function() { console.log("OK") })' + - '.run(function() { throw new Error("threw") })', -]); diff --git a/test/parallel/test-repl-eval-error-after-close.js b/test/parallel/test-repl-eval-error-after-close.js index 8c30a533efba15..0b4683fda4bfda 100644 --- a/test/parallel/test-repl-eval-error-after-close.js +++ b/test/parallel/test-repl-eval-error-after-close.js @@ -20,8 +20,6 @@ const assert = require('node:assert'); eval$.resolve(); }); }, - }, { - disableDomainErrorAssert: true, }); replServer.write('\n'); diff --git a/test/parallel/test-repl-let-process.js b/test/parallel/test-repl-let-process.js index eb6cbc6a472c72..22b57ab5bb977e 100644 --- a/test/parallel/test-repl-let-process.js +++ b/test/parallel/test-repl-let-process.js @@ -3,5 +3,5 @@ require('../common'); const { startNewREPLServer } = require('../common/repl'); // Regression test for https://github.com/nodejs/node/issues/6802 -const { input } = startNewREPLServer({ useGlobal: true }, { disableDomainErrorAssert: true }); +const { input } = startNewREPLServer({ useGlobal: true }); input.run(['let process']); diff --git a/test/parallel/test-repl-mode.js b/test/parallel/test-repl-mode.js index fbc9c18cba4998..0c5d2f70df7ce4 100644 --- a/test/parallel/test-repl-mode.js +++ b/test/parallel/test-repl-mode.js @@ -31,8 +31,8 @@ function testSloppyMode() { } function testStrictMode() { - const { input, output } = startNewREPLServer({ replMode: repl.REPL_MODE_STRICT, terminal: false, prompt: '> ' }, { - disableDomainErrorAssert: true, + const { input, output } = startNewREPLServer({ + replMode: repl.REPL_MODE_STRICT, terminal: false, prompt: '> ' }); input.emit('data', 'x = 3\n'); diff --git a/test/parallel/test-repl-multiple-instances-async-error.js b/test/parallel/test-repl-multiple-instances-async-error.js new file mode 100644 index 00000000000000..ddc8a5eaccdcca --- /dev/null +++ b/test/parallel/test-repl-multiple-instances-async-error.js @@ -0,0 +1,69 @@ +'use strict'; + +// This test verifies that when multiple REPL instances exist concurrently, +// async errors are correctly routed to the REPL instance that created them. + +const common = require('../common'); +const assert = require('assert'); +const repl = require('repl'); +const { Writable, PassThrough } = require('stream'); + +// Create two REPLs with separate inputs and outputs +let output1 = ''; +let output2 = ''; + +const input1 = new PassThrough(); +const input2 = new PassThrough(); + +const writable1 = new Writable({ + write(chunk, encoding, callback) { + output1 += chunk.toString(); + callback(); + } +}); + +const writable2 = new Writable({ + write(chunk, encoding, callback) { + output2 += chunk.toString(); + callback(); + } +}); + +const r1 = repl.start({ + input: input1, + output: writable1, + terminal: false, + prompt: 'R1> ', +}); + +const r2 = repl.start({ + input: input2, + output: writable2, + terminal: false, + prompt: 'R2> ', +}); + +// Create async error in REPL 1 +input1.write('setTimeout(() => { throw new Error("error from repl1") }, 10)\n'); + +// Create async error in REPL 2 +input2.write('setTimeout(() => { throw new Error("error from repl2") }, 20)\n'); + +setTimeout(common.mustCall(() => { + r1.close(); + r2.close(); + + // Verify error from REPL 1 went to REPL 1's output + assert.match(output1, /error from repl1/, + 'REPL 1 should have received its own async error'); + + // Verify error from REPL 2 went to REPL 2's output + assert.match(output2, /error from repl2/, + 'REPL 2 should have received its own async error'); + + // Verify errors did not cross over to wrong REPL + assert.doesNotMatch(output1, /error from repl2/, + 'REPL 1 should not have received REPL 2\'s error'); + assert.doesNotMatch(output2, /error from repl1/, + 'REPL 2 should not have received REPL 1\'s error'); +}), 100); diff --git a/test/parallel/test-repl-pretty-custom-stack.js b/test/parallel/test-repl-pretty-custom-stack.js index 82df8ff4fc6335..0efb814f38d2b5 100644 --- a/test/parallel/test-repl-pretty-custom-stack.js +++ b/test/parallel/test-repl-pretty-custom-stack.js @@ -10,8 +10,6 @@ function run({ command, expected }) { const { replServer, output } = startNewREPLServer({ terminal: false, useColors: false - }, { - disableDomainErrorAssert: true, }); replServer.write(`${command}\n`); diff --git a/test/parallel/test-repl-pretty-stack-custom-writer.js b/test/parallel/test-repl-pretty-stack-custom-writer.js index 2d39633030d775..e31460dbc93efb 100644 --- a/test/parallel/test-repl-pretty-stack-custom-writer.js +++ b/test/parallel/test-repl-pretty-stack-custom-writer.js @@ -5,10 +5,7 @@ const { startNewREPLServer } = require('../common/repl'); const testingReplPrompt = '_REPL_TESTING_PROMPT_>'; -const { replServer, output } = startNewREPLServer( - { prompt: testingReplPrompt }, - { disableDomainErrorAssert: true } -); +const { replServer, output } = startNewREPLServer({ prompt: testingReplPrompt }); replServer.write('throw new Error("foo[a]")\n'); diff --git a/test/parallel/test-repl-pretty-stack.js b/test/parallel/test-repl-pretty-stack.js index a7f13dea75aaea..b2f9cc82c08df0 100644 --- a/test/parallel/test-repl-pretty-stack.js +++ b/test/parallel/test-repl-pretty-stack.js @@ -7,14 +7,11 @@ const { startNewREPLServer } = require('../common/repl'); const stackRegExp = /(at .*REPL\d+:)[0-9]+:[0-9]+/g; function run({ command, expected, ...extraREPLOptions }, i) { - const { replServer, output } = startNewREPLServer( - { - terminal: false, - useColors: false, - ...extraREPLOptions - }, - { disableDomainErrorAssert: true } - ); + const { replServer, output } = startNewREPLServer({ + terminal: false, + useColors: false, + ...extraREPLOptions + }); replServer.write(`${command}\n`); if (typeof expected === 'string') { diff --git a/test/parallel/test-repl-preview-newlines.js b/test/parallel/test-repl-preview-newlines.js index 22ffe0db108590..34a944beb538d7 100644 --- a/test/parallel/test-repl-preview-newlines.js +++ b/test/parallel/test-repl-preview-newlines.js @@ -6,9 +6,7 @@ const { startNewREPLServer } = require('../common/repl'); common.skipIfInspectorDisabled(); -const { input, output } = startNewREPLServer( - { useColors: true }, { disableDomainErrorAssert: true } -); +const { input, output } = startNewREPLServer({ useColors: true }); output.accumulator = ''; diff --git a/test/parallel/test-repl-syntax-error-stack.js b/test/parallel/test-repl-syntax-error-stack.js index 1b6e3fb6e879f2..16bf27d045bc77 100644 --- a/test/parallel/test-repl-syntax-error-stack.js +++ b/test/parallel/test-repl-syntax-error-stack.js @@ -11,7 +11,7 @@ process.on('exit', () => { assert.strictEqual(found, true); }); -const { input, output } = startNewREPLServer({}, { disableDomainErrorAssert: true }); +const { input, output } = startNewREPLServer(); output.write = (data) => { // Matching only on a minimal piece of the stack because the string will vary diff --git a/test/parallel/test-repl-tab-complete-crash.js b/test/parallel/test-repl-tab-complete-crash.js index 58628eb85b2a38..29f75028bdac94 100644 --- a/test/parallel/test-repl-tab-complete-crash.js +++ b/test/parallel/test-repl-tab-complete-crash.js @@ -4,7 +4,7 @@ const common = require('../common'); const assert = require('assert'); const { startNewREPLServer } = require('../common/repl'); -const { replServer, input } = startNewREPLServer({}, { disableDomainErrorAssert: true }); +const { replServer, input } = startNewREPLServer(); // https://github.com/nodejs/node/issues/3346 // Tab-completion should be empty diff --git a/test/parallel/test-repl-tab.js b/test/parallel/test-repl-tab.js index e99f667c4a38f5..710fca9fae2d1e 100644 --- a/test/parallel/test-repl-tab.js +++ b/test/parallel/test-repl-tab.js @@ -10,6 +10,4 @@ const testMe = repl.start('', putIn, function(cmd, context, filename, callback(null, cmd); }); -testMe._domain.on('error', common.mustNotCall()); - testMe.complete('', common.mustSucceed()); diff --git a/test/parallel/test-repl-top-level-await.js b/test/parallel/test-repl-top-level-await.js index 0b35443dbdce14..a94ff8e48984a3 100644 --- a/test/parallel/test-repl-top-level-await.js +++ b/test/parallel/test-repl-top-level-await.js @@ -180,7 +180,7 @@ async function ordinaryTests() { ['k', '234'], ['const k = await Promise.resolve(345)', "Uncaught SyntaxError: Identifier 'k' has already been declared"], // Regression test for https://github.com/nodejs/node/issues/43777. - ['await Promise.resolve(123), Promise.resolve(456)', 'Promise {', { line: 0 }], + ['await Promise.resolve(123), Promise.resolve(456)', 'Promise { 456 }'], ['await Promise.resolve(123), await Promise.resolve(456)', '456'], ['await (Promise.resolve(123), Promise.resolve(456))', '456'], ]; diff --git a/test/parallel/test-repl-uncaught-exception-async.js b/test/parallel/test-repl-uncaught-exception-async.js index f4180080f496f7..e5373cdaca4d8d 100644 --- a/test/parallel/test-repl-uncaught-exception-async.js +++ b/test/parallel/test-repl-uncaught-exception-async.js @@ -8,17 +8,12 @@ const common = require('../common'); const assert = require('assert'); const { startNewREPLServer } = require('../common/repl'); -const { replServer, output } = startNewREPLServer( - { - prompt: '', - terminal: false, - useColors: false, - global: false, - }, - { - disableDomainErrorAssert: true - }, -); +const { replServer, output } = startNewREPLServer({ + prompt: '', + terminal: false, + useColors: false, + global: false, +}); replServer.write( 'process.nextTick(() => {\n' + diff --git a/test/parallel/test-repl-uncaught-exception-evalcallback.js b/test/parallel/test-repl-uncaught-exception-evalcallback.js index 77d03320ee9375..844fce6995aaa6 100644 --- a/test/parallel/test-repl-uncaught-exception-evalcallback.js +++ b/test/parallel/test-repl-uncaught-exception-evalcallback.js @@ -3,21 +3,16 @@ const common = require('../common'); const assert = require('assert'); const { startNewREPLServer } = require('../common/repl'); -const { replServer, output } = startNewREPLServer( - { - prompt: '', - terminal: false, - useColors: false, - global: false, - eval: common.mustCall((code, context, filename, cb) => { - replServer.setPrompt('prompt! '); - cb(new Error('err')); - }) - }, - { - disableDomainErrorAssert: true - }, -); +const { replServer, output } = startNewREPLServer({ + prompt: '', + terminal: false, + useColors: false, + global: false, + eval: common.mustCall((code, context, filename, cb) => { + replServer.setPrompt('prompt! '); + cb(new Error('err')); + }) +}); replServer.write('foo\n'); diff --git a/test/parallel/test-repl-uncaught-exception.js b/test/parallel/test-repl-uncaught-exception.js index 7753fe180b07fd..012c7f59ebc8a8 100644 --- a/test/parallel/test-repl-uncaught-exception.js +++ b/test/parallel/test-repl-uncaught-exception.js @@ -6,16 +6,11 @@ const { startNewREPLServer } = require('../common/repl'); let count = 0; function run({ command, expected, useColors = false }) { - const { replServer, output } = startNewREPLServer( - { - prompt: '', - terminal: false, - useColors, - }, - { - disableDomainErrorAssert: true - }, - ); + const { replServer, output } = startNewREPLServer({ + prompt: '', + terminal: false, + useColors, + }); replServer.write(`${command}\n`); diff --git a/test/parallel/test-repl-underscore.js b/test/parallel/test-repl-underscore.js index 52d4cd86e94176..63c63976e2ddd7 100644 --- a/test/parallel/test-repl-underscore.js +++ b/test/parallel/test-repl-underscore.js @@ -176,8 +176,6 @@ function testError() { prompt: testingReplPrompt, replMode: repl.REPL_MODE_STRICT, preview: false, - }, { - disableDomainErrorAssert: true }); replServer.write(`_error; // initial value undefined diff --git a/test/pummel/test-repl-paste-big-data.js b/test/pummel/test-repl-paste-big-data.js index 2265a1af8e393c..46f8415a2b0aae 100644 --- a/test/pummel/test-repl-paste-big-data.js +++ b/test/pummel/test-repl-paste-big-data.js @@ -8,7 +8,7 @@ const { startNewREPLServer } = require('../common/repl'); const cpuUsage = process.cpuUsage(); -const { replServer } = startNewREPLServer({}, { disableDomainErrorAssert: true }); +const { replServer } = startNewREPLServer(); replServer.input.emit('data', '{}'); replServer.input.emit('keypress', '', { name: 'left' }); replServer.input.emit('data', 'node'); From 7963ccb6efad96f290765468d69362b6fa7c2f3a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 13 Jan 2026 22:37:09 +0100 Subject: [PATCH 2/3] repl: restore domain module compatibility Add auxiliary callback mechanism to setUncaughtExceptionCaptureCallback to allow REPL and domain module to coexist. The REPL uses the new addUncaughtExceptionCaptureCallback API which doesn't conflict with domain's use of the primary callback. - Add addUncaughtExceptionCaptureCallback for non-exclusive callbacks - Update REPL to check for active domain before handling errors - Remove mutual exclusivity checks from domain module - Restore test-repl-domain.js test - Update domain coexistence tests --- lib/domain.js | 20 +------- lib/internal/bootstrap/node.js | 3 ++ lib/internal/process/execution.js | 44 +++++++++++++++-- lib/repl.js | 44 ++++++++--------- ...ad-after-set-uncaught-exception-capture.js | 25 ++++++---- ...t-uncaught-exception-capture-after-load.js | 37 ++++++-------- test/parallel/test-repl-domain.js | 49 +++++++++++++++++++ 7 files changed, 144 insertions(+), 78 deletions(-) create mode 100644 test/parallel/test-repl-domain.js diff --git a/lib/domain.js b/lib/domain.js index 7dd16ee1bf59ef..f3a42271d2326d 100644 --- a/lib/domain.js +++ b/lib/domain.js @@ -40,14 +40,11 @@ const { ReflectApply, SafeMap, SafeWeakMap, - StringPrototypeRepeat, Symbol, } = primordials; const EventEmitter = require('events'); const { - ERR_DOMAIN_CALLBACK_NOT_AVAILABLE, - ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE, ERR_UNHANDLED_ERROR, } = require('internal/errors').codes; const { createHook } = require('async_hooks'); @@ -119,22 +116,9 @@ const asyncHook = createHook({ }, }); -// When domains are in use, they claim full ownership of the -// uncaught exception capture callback. -if (process.hasUncaughtExceptionCaptureCallback()) { - throw new ERR_DOMAIN_CALLBACK_NOT_AVAILABLE(); -} - -// Get the stack trace at the point where `domain` was required. -// eslint-disable-next-line no-restricted-syntax -const domainRequireStack = new Error('require(`domain`) at this point').stack; - +// Domain uses the stacking capability of setUncaughtExceptionCaptureCallback +// to coexist with other callbacks (e.g., REPL). const { setUncaughtExceptionCaptureCallback } = process; -process.setUncaughtExceptionCaptureCallback = function(fn) { - const err = new ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE(); - err.stack += `\n${StringPrototypeRepeat('-', 40)}\n${domainRequireStack}`; - throw err; -}; let sendMakeCallbackDeprecation = false; diff --git a/lib/internal/bootstrap/node.js b/lib/internal/bootstrap/node.js index 20f1e04c47c843..16418adde8338d 100644 --- a/lib/internal/bootstrap/node.js +++ b/lib/internal/bootstrap/node.js @@ -306,6 +306,7 @@ ObjectDefineProperty(process, 'features', { const { onGlobalUncaughtException, setUncaughtExceptionCaptureCallback, + addUncaughtExceptionCaptureCallback, hasUncaughtExceptionCaptureCallback, } = require('internal/process/execution'); @@ -318,6 +319,8 @@ ObjectDefineProperty(process, 'features', { process._fatalException = onGlobalUncaughtException; process.setUncaughtExceptionCaptureCallback = setUncaughtExceptionCaptureCallback; + process.addUncaughtExceptionCaptureCallback = + addUncaughtExceptionCaptureCallback; process.hasUncaughtExceptionCaptureCallback = hasUncaughtExceptionCaptureCallback; } diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index f2407949c2a1b6..0e91802f76aa2c 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayPrototypePush, RegExpPrototypeExec, StringPrototypeIndexOf, StringPrototypeSlice, @@ -105,15 +106,18 @@ function evalScript(name, body, breakFirstLine, print, shouldLoadESM = false) { } const exceptionHandlerState = { - captureFn: null, + captureFn: null, // Primary callback (for domain's exclusive use) + auxiliaryCallbacks: [], // Auxiliary callbacks (for REPL, etc.) - always called reportFlag: false, }; function setUncaughtExceptionCaptureCallback(fn) { if (fn === null) { exceptionHandlerState.captureFn = fn; - shouldAbortOnUncaughtToggle[0] = 1; - process.report.reportOnUncaughtException = exceptionHandlerState.reportFlag; + if (exceptionHandlerState.auxiliaryCallbacks.length === 0) { + shouldAbortOnUncaughtToggle[0] = 1; + process.report.reportOnUncaughtException = exceptionHandlerState.reportFlag; + } return; } if (typeof fn !== 'function') { @@ -129,6 +133,23 @@ function setUncaughtExceptionCaptureCallback(fn) { process.report.reportOnUncaughtException = false; } +// Add an auxiliary callback that coexists with the primary callback. +// Auxiliary callbacks are called first; if any returns true, the error is handled. +// Otherwise, the primary callback (if set) is called. +function addUncaughtExceptionCaptureCallback(fn) { + if (typeof fn !== 'function') { + throw new ERR_INVALID_ARG_TYPE('fn', 'Function', fn); + } + if (exceptionHandlerState.auxiliaryCallbacks.length === 0 && + exceptionHandlerState.captureFn === null) { + exceptionHandlerState.reportFlag = + process.report.reportOnUncaughtException === true; + process.report.reportOnUncaughtException = false; + shouldAbortOnUncaughtToggle[0] = 0; + } + ArrayPrototypePush(exceptionHandlerState.auxiliaryCallbacks, fn); +} + function hasUncaughtExceptionCaptureCallback() { return exceptionHandlerState.captureFn !== null; } @@ -154,9 +175,21 @@ function createOnGlobalUncaughtException() { const type = fromPromise ? 'unhandledRejection' : 'uncaughtException'; process.emit('uncaughtExceptionMonitor', er, type); + let handled = false; + // Primary callback (e.g., domain) has priority - it handles domain-specific errors if (exceptionHandlerState.captureFn !== null) { - exceptionHandlerState.captureFn(er); - } else if (!process.emit('uncaughtException', er, type)) { + handled = exceptionHandlerState.captureFn(er); + } + // If primary didn't handle it, try auxiliary callbacks (e.g., REPL) + if (!handled) { + for (let i = exceptionHandlerState.auxiliaryCallbacks.length - 1; i >= 0; i--) { + if (exceptionHandlerState.auxiliaryCallbacks[i](er) === true) { + handled = true; + break; + } + } + } + if (!handled && !process.emit('uncaughtException', er, type)) { // If someone handled it, then great. Otherwise, die in C++ land // since that means that we'll exit the process, emit the 'exit' event. try { @@ -473,5 +506,6 @@ module.exports = { evalScript, onGlobalUncaughtException: createOnGlobalUncaughtException(), setUncaughtExceptionCaptureCallback, + addUncaughtExceptionCaptureCallback, hasUncaughtExceptionCaptureCallback, }; diff --git a/lib/repl.js b/lib/repl.js index 2eee3def4e6909..4b90a9a87a0c3e 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -187,36 +187,22 @@ let exceptionCaptureSetup = false; /** * Sets up the uncaught exception capture callback to route errors * to the appropriate REPL instance. This replaces domain-based error handling. - * @returns {boolean} true if setup succeeded, false if capture callback already in use + * Uses addUncaughtExceptionCaptureCallback to coexist with the primary + * callback (e.g., domain module). */ function setupExceptionCapture() { - if (exceptionCaptureSetup) return true; + if (exceptionCaptureSetup) return; - if (process.hasUncaughtExceptionCaptureCallback()) { - return false; - } - - process.setUncaughtExceptionCaptureCallback((err) => { + process.addUncaughtExceptionCaptureCallback((err) => { const store = replContext.getStore(); if (store?.replServer && !store.replServer.closed) { store.replServer._handleError(err); - return; - } - // No active REPL context - re-emit for normal process handling - process.setUncaughtExceptionCaptureCallback(null); - exceptionCaptureSetup = false; - try { - if (!process.emit('uncaughtException', err, 'uncaughtException')) { - // No handler, rethrow to exit process - throw err; - } - } finally { - setupExceptionCapture(); + return true; // We handled it } + // No active REPL context - let other handlers try }); exceptionCaptureSetup = true; - return true; } const kBufferedCommandSymbol = Symbol('bufferedCommand'); @@ -624,6 +610,12 @@ class REPLServer extends Interface { } } catch (e) { err = e; + // If there's an active domain with error listeners, let it handle the error + if (process.domain?.listenerCount('error') > 0) { + debug('domain handling error'); + process.domain.emit('error', err); + return; + } // Handle non-recoverable errors directly debug('not recoverable, handle error'); self._handleError(err); @@ -653,9 +645,15 @@ class REPLServer extends Interface { const result = (await promise)?.value; finishExecution(null, result); } catch (err) { - // Handle non-recoverable async errors directly - debug('not recoverable, handle error'); - self._handleError(err); + // If there's an active domain with error listeners, let it handle the error + if (process.domain?.listenerCount('error') > 0) { + debug('domain handling async error'); + process.domain.emit('error', err); + } else { + // Handle non-recoverable async errors directly + debug('not recoverable, handle error'); + self._handleError(err); + } } finally { // Remove prioritized SIGINT listener if it was not called. prioritizedSigintQueue.delete(sigintListener); diff --git a/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js b/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js index 4018220711517f..73f5f989b8e776 100644 --- a/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js +++ b/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js @@ -1,17 +1,22 @@ 'use strict'; +// Tests that domain can be loaded after setUncaughtExceptionCaptureCallback +// has been called. This verifies that the mutual exclusivity has been removed. const common = require('../common'); -const assert = require('assert'); +// Set up a capture callback first process.setUncaughtExceptionCaptureCallback(common.mustNotCall()); -assert.throws( - () => require('domain'), - { - code: 'ERR_DOMAIN_CALLBACK_NOT_AVAILABLE', - name: 'Error', - message: /^A callback was registered.*with using the `domain` module/ - } -); +// Loading domain should not throw (coexistence is now supported) +const domain = require('domain'); + +// Verify domain module loaded successfully +const assert = require('assert'); +assert.ok(domain); +assert.ok(domain.create); +// Clean up process.setUncaughtExceptionCaptureCallback(null); -require('domain'); // Should not throw. + +// Domain should still be usable +const d = domain.create(); +assert.ok(d); diff --git a/test/parallel/test-domain-set-uncaught-exception-capture-after-load.js b/test/parallel/test-domain-set-uncaught-exception-capture-after-load.js index 4bf419d76eb453..64f129fd201781 100644 --- a/test/parallel/test-domain-set-uncaught-exception-capture-after-load.js +++ b/test/parallel/test-domain-set-uncaught-exception-capture-after-load.js @@ -1,30 +1,23 @@ 'use strict'; +// Tests that setUncaughtExceptionCaptureCallback can be called after domain +// is loaded. This verifies that the mutual exclusivity has been removed. const common = require('../common'); const assert = require('assert'); -Error.stackTraceLimit = Infinity; +// Load domain first +const domain = require('domain'); +assert.ok(domain); -(function foobar() { - require('domain'); -})(); +// Setting callback should not throw (coexistence is now supported) +process.setUncaughtExceptionCaptureCallback(common.mustNotCall()); -assert.throws( - () => process.setUncaughtExceptionCaptureCallback(common.mustNotCall()), - (err) => { - common.expectsError( - { - code: 'ERR_DOMAIN_CANNOT_SET_UNCAUGHT_EXCEPTION_CAPTURE', - name: 'Error', - message: /^The `domain` module is in use, which is mutually/ - } - )(err); +// Verify callback is registered +assert.ok(process.hasUncaughtExceptionCaptureCallback()); - assert(err.stack.includes('-'.repeat(40)), - `expected ${err.stack} to contain dashes`); +// Clean up +process.setUncaughtExceptionCaptureCallback(null); +assert.ok(!process.hasUncaughtExceptionCaptureCallback()); - const location = `at foobar (${__filename}:`; - assert(err.stack.includes(location), - `expected ${err.stack} to contain ${location}`); - return true; - } -); +// Domain should still be usable after callback operations +const d = domain.create(); +assert.ok(d); diff --git a/test/parallel/test-repl-domain.js b/test/parallel/test-repl-domain.js new file mode 100644 index 00000000000000..b7c8d95dd26c0e --- /dev/null +++ b/test/parallel/test-repl-domain.js @@ -0,0 +1,49 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +const { startNewREPLServer } = require('../common/repl'); +const ArrayStream = require('../common/arraystream'); + +const stream = new ArrayStream(); + +startNewREPLServer({ + input: stream, + output: stream, + terminal: false, +}); + +stream.write = function(data) { + // Don't use assert for this because the domain might catch it, and + // give a false negative. Don't throw, just print and exit. + if (data === 'OK\n') { + console.log('ok'); + } else { + console.error(data); + process.exit(1); + } +}; + +stream.run([ + 'require("domain").create().on("error", function() { console.log("OK") })' + + '.run(function() { throw new Error("threw") })', +]); From 74ba1a5e93cfbf371786fa705f003552950fd70c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 13 Jan 2026 22:46:30 +0100 Subject: [PATCH 3/3] doc: add process.addUncaughtExceptionCaptureCallback --- doc/api/process.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/doc/api/process.md b/doc/api/process.md index 04ca0eff9c55d4..5544d6ef13b451 100644 --- a/doc/api/process.md +++ b/doc/api/process.md @@ -736,6 +736,42 @@ generate a core file. This feature is not available in [`Worker`][] threads. +## `process.addUncaughtExceptionCaptureCallback(fn)` + + + +* `fn` {Function} + +The `process.addUncaughtExceptionCaptureCallback()` function adds a callback +that will be invoked when an uncaught exception occurs, receiving the exception +value as its first argument. + +Unlike [`process.setUncaughtExceptionCaptureCallback()`][], this function allows +multiple callbacks to be registered and does not conflict with the +[`domain`][] module. Callbacks are called in reverse order of registration +(most recent first). If a callback returns `true`, subsequent callbacks +and the default uncaught exception handling are skipped. + +```mjs +import process from 'node:process'; + +process.addUncaughtExceptionCaptureCallback((err) => { + console.error('Caught exception:', err.message); + return true; // Indicates exception was handled +}); +``` + +```cjs +const process = require('node:process'); + +process.addUncaughtExceptionCaptureCallback((err) => { + console.error('Caught exception:', err.message); + return true; // Indicates exception was handled +}); +``` + ## `process.allowedNodeEnvironmentFlags`