From 0bbc62c42a29c883af07cc697adc2fe643f6d8e3 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Mon, 6 May 2024 22:16:22 +0200 Subject: [PATCH] process: improve event-loop PR-URL: https://github.com/nodejs/node/pull/52108 Reviewed-By: Matteo Collina Reviewed-By: Yagiz Nizipli Reviewed-By: Benjamin Gruenbaum --- lib/internal/process/promises.js | 511 +++++++++++------- .../unhandled_promise_trace_warnings.snapshot | 1 + .../test-promise-unhandled-default.js | 4 +- test/parallel/test-promise-unhandled-error.js | 3 +- .../test-promise-unhandled-issue-43655.js | 27 + test/parallel/test-promise-unhandled-throw.js | 3 +- 6 files changed, 359 insertions(+), 190 deletions(-) create mode 100644 test/parallel/test-promise-unhandled-issue-43655.js diff --git a/lib/internal/process/promises.js b/lib/internal/process/promises.js index d80ce1ef764a00..5e5404e1612178 100644 --- a/lib/internal/process/promises.js +++ b/lib/internal/process/promises.js @@ -4,8 +4,8 @@ const { ArrayPrototypePush, ArrayPrototypeShift, Error, - ObjectDefineProperty, ObjectPrototypeHasOwnProperty, + SafeMap, SafeWeakMap, } = primordials; @@ -14,8 +14,8 @@ const { promiseRejectEvents: { kPromiseRejectWithNoHandler, kPromiseHandlerAddedAfterReject, - kPromiseResolveAfterResolved, kPromiseRejectAfterResolved, + kPromiseResolveAfterResolved, }, setPromiseRejectCallback, } = internalBinding('task_queue'); @@ -41,88 +41,163 @@ const { isErrorStackTraceLimitWritable } = require('internal/errors'); // *Must* match Environment::TickInfo::Fields in src/env.h. const kHasRejectionToWarn = 1; -const maybeUnhandledPromises = new SafeWeakMap(); -const pendingUnhandledRejections = []; -const asyncHandledRejections = []; -let lastPromiseId = 0; +// By default true because in cases where process is not a global +// it is not possible to determine if the user has added a listener +// to the process object. +let hasMultipleResolvesListener = true; + +if (process.on) { + hasMultipleResolvesListener = process.listenerCount('multipleResolves') !== 0; + + process.on('newListener', (eventName) => { + if (eventName === 'multipleResolves') { + hasMultipleResolvesListener = true; + } + }); + + process.on('removeListener', (eventName) => { + if (eventName === 'multipleResolves') { + hasMultipleResolvesListener = process.listenerCount('multipleResolves') !== 0; + } + }); +} -// --unhandled-rejections=none: -// Emit 'unhandledRejection', but do not emit any warning. -const kIgnoreUnhandledRejections = 0; +/** + * Errors & Warnings + */ + +class UnhandledPromiseRejection extends Error { + code = 'ERR_UNHANDLED_REJECTION'; + name = 'UnhandledPromiseRejection'; + /** + * @param {Error} reason + */ + constructor(reason) { + super('This error originated either by throwing inside of an ' + + 'async function without a catch block, or by rejecting a promise which ' + + 'was not handled with .catch(). The promise rejected with the reason "' + + noSideEffectsToString(reason) + '".'); + } +} -// --unhandled-rejections=warn: -// Emit 'unhandledRejection', then emit 'UnhandledPromiseRejectionWarning'. -const kAlwaysWarnUnhandledRejections = 1; +class UnhandledPromiseRejectionWarning extends Error { + name = 'UnhandledPromiseRejectionWarning'; + /** + * @param {number} uid + */ + constructor(uid) { + const message = 'Unhandled promise rejection. This error originated either by ' + + 'throwing inside of an async function without a catch block, ' + + 'or by rejecting a promise which was not handled with .catch(). ' + + 'To terminate the node process on unhandled promise ' + + 'rejection, use the CLI flag `--unhandled-rejections=strict` (see ' + + 'https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). ' + + `(rejection id: ${uid})`; + + // UnhandledPromiseRejectionWarning will get the stack trace from the + // reason, so we can disable the stack trace limit temporarily for better + // performance. + if (isErrorStackTraceLimitWritable()) { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + super(message); + Error.stackTraceLimit = stackTraceLimit; + } else { + super(message); + } + } +} -// --unhandled-rejections=strict: -// Emit 'uncaughtException'. If it's not handled, print the error to stderr -// and exit the process. -// Otherwise, emit 'unhandledRejection'. If 'unhandledRejection' is not -// handled, emit 'UnhandledPromiseRejectionWarning'. -const kStrictUnhandledRejections = 2; +class PromiseRejectionHandledWarning extends Error { + name = 'PromiseRejectionHandledWarning'; -// --unhandled-rejections=throw: -// Emit 'unhandledRejection', if it's unhandled, emit -// 'uncaughtException'. If it's not handled, print the error to stderr -// and exit the process. -const kThrowUnhandledRejections = 3; + /** + * @param {number} uid + */ + constructor(uid) { + super(`Promise rejection was handled asynchronously (rejection id: ${uid})`); + this.id = uid; + } +} -// --unhandled-rejections=warn-with-error-code: -// Emit 'unhandledRejection', if it's unhandled, emit -// 'UnhandledPromiseRejectionWarning', then set process exit code to 1. +/** + * @typedef PromiseInfo + * @property {*} reason the reason for the rejection + * @property {number} uid the unique id of the promise + * @property {boolean} warned whether the rejection has been warned + * @property {object} [domain] the domain the promise was created in + */ + +/** + * @type {WeakMap} + */ +const maybeUnhandledPromises = new SafeWeakMap(); -const kWarnWithErrorCodeUnhandledRejections = 4; +/** + * Using a Mp causes the promise to be referenced at least for one tick. + * @type {Map} + */ +let pendingUnhandledRejections = new SafeMap(); -let unhandledRejectionsMode; +/** + * @type {Array<{promise: Promise, warning: Error}>} + */ +const asyncHandledRejections = []; + +/** + * @type {number} + */ +let lastPromiseId = 0; +/** + * @param {boolean} value + */ function setHasRejectionToWarn(value) { tickInfo[kHasRejectionToWarn] = value ? 1 : 0; } +/** + * @returns {boolean} + */ function hasRejectionToWarn() { return tickInfo[kHasRejectionToWarn] === 1; } -function isErrorLike(o) { - return typeof o === 'object' && - o !== null && - ObjectPrototypeHasOwnProperty(o, 'stack'); -} - -function getUnhandledRejectionsMode() { - const { getOptionValue } = require('internal/options'); - switch (getOptionValue('--unhandled-rejections')) { - case 'none': - return kIgnoreUnhandledRejections; - case 'warn': - return kAlwaysWarnUnhandledRejections; - case 'strict': - return kStrictUnhandledRejections; - case 'throw': - return kThrowUnhandledRejections; - case 'warn-with-error-code': - return kWarnWithErrorCodeUnhandledRejections; - default: - return kThrowUnhandledRejections; - } +/** + * @param {string|Error} obj + * @returns {obj is Error} + */ +function isErrorLike(obj) { + return typeof obj === 'object' && + obj !== null && + ObjectPrototypeHasOwnProperty(obj, 'stack'); } +/** + * @param {0|1|2|3} type + * @param {Promise} promise + * @param {Error} reason + */ function promiseRejectHandler(type, promise, reason) { if (unhandledRejectionsMode === undefined) { unhandledRejectionsMode = getUnhandledRejectionsMode(); } switch (type) { - case kPromiseRejectWithNoHandler: + case kPromiseRejectWithNoHandler: // 0 unhandledRejection(promise, reason); break; - case kPromiseHandlerAddedAfterReject: + case kPromiseHandlerAddedAfterReject: // 1 handledRejection(promise); break; - case kPromiseResolveAfterResolved: - resolveError('resolve', promise, reason); + case kPromiseRejectAfterResolved: // 2 + if (hasMultipleResolvesListener) { + resolveErrorReject(promise, reason); + } break; - case kPromiseRejectAfterResolved: - resolveError('reject', promise, reason); + case kPromiseResolveAfterResolved: // 3 + if (hasMultipleResolvesListener) { + resolveErrorResolve(promise, reason); + } break; } } @@ -132,69 +207,91 @@ const multipleResolvesDeprecate = deprecate( 'The multipleResolves event has been deprecated.', 'DEP0160', ); -function resolveError(type, promise, reason) { + +/** + * @param {Promise} promise + * @param {Error} reason + */ +function resolveErrorResolve(promise, reason) { // We have to wrap this in a next tick. Otherwise the error could be caught by // the executed promise. process.nextTick(() => { - if (process.emit('multipleResolves', type, promise, reason)) { + // Emit the multipleResolves event. + // This is a deprecated event, so we have to check if it's being listened to. + if (process.emit('multipleResolves', 'resolve', promise, reason)) { + // If the event is being listened to, emit a deprecation warning. multipleResolvesDeprecate(); } }); } -function unhandledRejection(promise, reason) { - const emit = (reason, promise, promiseInfo) => { - if (promiseInfo.domain) { - return promiseInfo.domain.emit('error', reason); +/** + * @param {Promise} promise + * @param {Error} reason + */ +function resolveErrorReject(promise, reason) { + // We have to wrap this in a next tick. Otherwise the error could be caught by + // the executed promise. + process.nextTick(() => { + if (process.emit('multipleResolves', 'reject', promise, reason)) { + multipleResolvesDeprecate(); } - return process.emit('unhandledRejection', reason, promise); - }; + }); +} + +/** + * @param {Promise} promise + * @param {PromiseInfo} promiseInfo + * @returns {boolean} + */ +const emitUnhandledRejection = (promise, promiseInfo) => { + return promiseInfo.domain ? + promiseInfo.domain.emit('error', promiseInfo.reason) : + process.emit('unhandledRejection', promiseInfo.reason, promise); +}; - maybeUnhandledPromises.set(promise, { +/** + * @param {Promise} promise + * @param {Error} reason + */ +function unhandledRejection(promise, reason) { + pendingUnhandledRejections.set(promise, { reason, uid: ++lastPromiseId, warned: false, domain: process.domain, - emit, }); - // This causes the promise to be referenced at least for one tick. - ArrayPrototypePush(pendingUnhandledRejections, promise); setHasRejectionToWarn(true); } +/** + * @param {Promise} promise + */ function handledRejection(promise) { + if (pendingUnhandledRejections.has(promise)) { + pendingUnhandledRejections.delete(promise); + return; + } const promiseInfo = maybeUnhandledPromises.get(promise); if (promiseInfo !== undefined) { maybeUnhandledPromises.delete(promise); if (promiseInfo.warned) { - const { uid } = promiseInfo; // Generate the warning object early to get a good stack trace. - // eslint-disable-next-line no-restricted-syntax - const warning = new Error('Promise rejection was handled ' + - `asynchronously (rejection id: ${uid})`); - warning.name = 'PromiseRejectionHandledWarning'; - warning.id = uid; + const warning = new PromiseRejectionHandledWarning(promiseInfo.uid); ArrayPrototypePush(asyncHandledRejections, { promise, warning }); setHasRejectionToWarn(true); - return; } } - if (maybeUnhandledPromises.size === 0 && asyncHandledRejections.length === 0) - setHasRejectionToWarn(false); } -const unhandledRejectionErrName = 'UnhandledPromiseRejectionWarning'; -function emitUnhandledRejectionWarning(uid, reason) { - const warning = getErrorWithoutStack( - unhandledRejectionErrName, - 'Unhandled promise rejection. This error originated either by ' + - 'throwing inside of an async function without a catch block, ' + - 'or by rejecting a promise which was not handled with .catch(). ' + - 'To terminate the node process on unhandled promise ' + - 'rejection, use the CLI flag `--unhandled-rejections=strict` (see ' + - 'https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). ' + - `(rejection id: ${uid})`, - ); +const unhandledRejectionErrName = UnhandledPromiseRejectionWarning.name; + +/** + * @param {PromiseInfo} promiseInfo + */ +function emitUnhandledRejectionWarning(promiseInfo) { + const warning = new UnhandledPromiseRejectionWarning(promiseInfo.uid); + const reason = promiseInfo.reason; try { if (isErrorLike(reason)) { warning.stack = reason.stack; @@ -215,137 +312,177 @@ function emitUnhandledRejectionWarning(uid, reason) { process.emitWarning(warning); } +/** + * @callback UnhandledRejectionsModeHandler + * @param {Promise} promise + * @param {PromiseInfo} promiseInfo + * @param {number} [promiseAsyncId] + * @returns {boolean} + */ + +/** + * The mode of unhandled rejections. + * @type {UnhandledRejectionsModeHandler} + */ +let unhandledRejectionsMode; + +/** + * --unhandled-rejections=strict: + * Emit 'uncaughtException'. If it's not handled, print the error to stderr + * and exit the process. + * Otherwise, emit 'unhandledRejection'. If 'unhandledRejection' is not + * handled, emit 'UnhandledPromiseRejectionWarning'. + * @type {UnhandledRejectionsModeHandler} + */ +function strictUnhandledRejectionsMode(promise, promiseInfo, promiseAsyncId) { + const reason = promiseInfo.reason; + const err = isErrorLike(reason) ? + reason : new UnhandledPromiseRejection(reason); + // This destroys the async stack, don't clear it after + triggerUncaughtException(err, true /* fromPromise */); + if (promiseAsyncId === undefined) { + pushAsyncContext( + promise[kAsyncIdSymbol], + promise[kTriggerAsyncIdSymbol], + promise, + ); + } + const handled = emitUnhandledRejection(promise, promiseInfo); + if (!handled) emitUnhandledRejectionWarning(promiseInfo); + return true; +} + +/** + * --unhandled-rejections=none: + * Emit 'unhandledRejection', but do not emit any warning. + * @type {UnhandledRejectionsModeHandler} + */ +function ignoreUnhandledRejectionsMode(promise, promiseInfo) { + emitUnhandledRejection(promise, promiseInfo); + return true; +} + +/** + * --unhandled-rejections=warn: + * Emit 'unhandledRejection', then emit 'UnhandledPromiseRejectionWarning'. + * @type {UnhandledRejectionsModeHandler} + */ +function alwaysWarnUnhandledRejectionsMode(promise, promiseInfo) { + emitUnhandledRejection(promise, promiseInfo); + emitUnhandledRejectionWarning(promiseInfo); + return true; +} + +/** + * --unhandled-rejections=throw: + * Emit 'unhandledRejection', if it's unhandled, emit + * 'uncaughtException'. If it's not handled, print the error to stderr + * and exit the process. + * @type {UnhandledRejectionsModeHandler} + */ +function throwUnhandledRejectionsMode(promise, promiseInfo) { + const reason = promiseInfo.reason; + const handled = emitUnhandledRejection(promise, promiseInfo); + if (!handled) { + const err = isErrorLike(reason) ? + reason : + new UnhandledPromiseRejection(reason); + // This destroys the async stack, don't clear it after + triggerUncaughtException(err, true /* fromPromise */); + return false; + } + return true; +} + +/** + * --unhandled-rejections=warn-with-error-code: + * Emit 'unhandledRejection', if it's unhandled, emit + * 'UnhandledPromiseRejectionWarning', then set process exit code to 1. + * @type {UnhandledRejectionsModeHandler} + */ +function warnWithErrorCodeUnhandledRejectionsMode(promise, promiseInfo) { + const handled = emitUnhandledRejection(promise, promiseInfo); + if (!handled) { + emitUnhandledRejectionWarning(promiseInfo); + process.exitCode = kGenericUserError; + } + return true; +} + +/** + * @returns {UnhandledRejectionsModeHandler} + */ +function getUnhandledRejectionsMode() { + const { getOptionValue } = require('internal/options'); + switch (getOptionValue('--unhandled-rejections')) { + case 'none': + return ignoreUnhandledRejectionsMode; + case 'warn': + return alwaysWarnUnhandledRejectionsMode; + case 'strict': + return strictUnhandledRejectionsMode; + case 'throw': + return throwUnhandledRejectionsMode; + case 'warn-with-error-code': + return warnWithErrorCodeUnhandledRejectionsMode; + default: + return throwUnhandledRejectionsMode; + } +} + // If this method returns true, we've executed user code or triggered // a warning to be emitted which requires the microtask and next tick // queues to be drained again. function processPromiseRejections() { let maybeScheduledTicksOrMicrotasks = asyncHandledRejections.length > 0; - while (asyncHandledRejections.length > 0) { + while (asyncHandledRejections.length !== 0) { const { promise, warning } = ArrayPrototypeShift(asyncHandledRejections); if (!process.emit('rejectionHandled', promise)) { process.emitWarning(warning); } } - let len = pendingUnhandledRejections.length; - while (len--) { - const promise = ArrayPrototypeShift(pendingUnhandledRejections); - const promiseInfo = maybeUnhandledPromises.get(promise); - if (promiseInfo === undefined) { - continue; - } + let needPop = true; + let promiseAsyncId; + + const pending = pendingUnhandledRejections; + pendingUnhandledRejections = new SafeMap(); + + for (const { 0: promise, 1: promiseInfo } of pending.entries()) { + maybeUnhandledPromises.set(promise, promiseInfo); + promiseInfo.warned = true; - const { reason, uid, emit } = promiseInfo; - let needPop = true; - const { - [kAsyncIdSymbol]: promiseAsyncId, - [kTriggerAsyncIdSymbol]: promiseTriggerAsyncId, - } = promise; // We need to check if async_hooks are enabled // don't use enabledHooksExist as a Promise could // come from a vm.* context and not have an async id - if (typeof promiseAsyncId !== 'undefined') { + promiseAsyncId = promise[kAsyncIdSymbol]; + if (promiseAsyncId !== undefined) { pushAsyncContext( promiseAsyncId, - promiseTriggerAsyncId, + promise[kTriggerAsyncIdSymbol], promise, ); } + try { - switch (unhandledRejectionsMode) { - case kStrictUnhandledRejections: { - const err = isErrorLike(reason) ? - reason : generateUnhandledRejectionError(reason); - // This destroys the async stack, don't clear it after - triggerUncaughtException(err, true /* fromPromise */); - if (typeof promiseAsyncId !== 'undefined') { - pushAsyncContext( - promise[kAsyncIdSymbol], - promise[kTriggerAsyncIdSymbol], - promise, - ); - } - const handled = emit(reason, promise, promiseInfo); - if (!handled) emitUnhandledRejectionWarning(uid, reason); - break; - } - case kIgnoreUnhandledRejections: { - emit(reason, promise, promiseInfo); - break; - } - case kAlwaysWarnUnhandledRejections: { - emit(reason, promise, promiseInfo); - emitUnhandledRejectionWarning(uid, reason); - break; - } - case kThrowUnhandledRejections: { - const handled = emit(reason, promise, promiseInfo); - if (!handled) { - const err = isErrorLike(reason) ? - reason : generateUnhandledRejectionError(reason); - // This destroys the async stack, don't clear it after - triggerUncaughtException(err, true /* fromPromise */); - needPop = false; - } - break; - } - case kWarnWithErrorCodeUnhandledRejections: { - const handled = emit(reason, promise, promiseInfo); - if (!handled) { - emitUnhandledRejectionWarning(uid, reason); - process.exitCode = kGenericUserError; - } - break; - } - } + needPop = unhandledRejectionsMode(promise, promiseInfo, promiseAsyncId); } finally { - if (needPop) { - if (typeof promiseAsyncId !== 'undefined') { - popAsyncContext(promiseAsyncId); - } - } + needPop && + promiseAsyncId !== undefined && + popAsyncContext(promiseAsyncId); } maybeScheduledTicksOrMicrotasks = true; } return maybeScheduledTicksOrMicrotasks || - pendingUnhandledRejections.length !== 0; -} - -function getErrorWithoutStack(name, message) { - // Reset the stack to prevent any overhead. - const tmp = Error.stackTraceLimit; - if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = 0; - // eslint-disable-next-line no-restricted-syntax - const err = new Error(message); - if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = tmp; - ObjectDefineProperty(err, 'name', { - __proto__: null, - value: name, - enumerable: false, - writable: true, - configurable: true, - }); - return err; -} - -function generateUnhandledRejectionError(reason) { - const message = - 'This error originated either by ' + - 'throwing inside of an async function without a catch block, ' + - 'or by rejecting a promise which was not handled with .catch().' + - ' The promise rejected with the reason ' + - `"${noSideEffectsToString(reason)}".`; - - const err = getErrorWithoutStack('UnhandledPromiseRejection', message); - err.code = 'ERR_UNHANDLED_REJECTION'; - return err; + pendingUnhandledRejections.size !== 0; } function listenForRejections() { setPromiseRejectCallback(promiseRejectHandler); } + module.exports = { hasRejectionToWarn, setHasRejectionToWarn, diff --git a/test/fixtures/errors/unhandled_promise_trace_warnings.snapshot b/test/fixtures/errors/unhandled_promise_trace_warnings.snapshot index 1ed082d0736851..bf50fffd60c5ee 100644 --- a/test/fixtures/errors/unhandled_promise_trace_warnings.snapshot +++ b/test/fixtures/errors/unhandled_promise_trace_warnings.snapshot @@ -9,6 +9,7 @@ at * at * at * + at * (node:*) Error: This was rejected at * at * diff --git a/test/parallel/test-promise-unhandled-default.js b/test/parallel/test-promise-unhandled-default.js index f69a039bed21da..c8cbe0c41d279a 100644 --- a/test/parallel/test-promise-unhandled-default.js +++ b/test/parallel/test-promise-unhandled-default.js @@ -46,7 +46,9 @@ process.on('uncaughtException', common.mustCall((err, origin) => { counter.dec(); assert.strictEqual(origin, 'unhandledRejection', err); const knownError = errors.shift(); - assert.deepStrictEqual(err, knownError); + assert.strictEqual(err.name, knownError.name); + assert.strictEqual(err.toString(), knownError.toString()); + assert.strictEqual(err.code, knownError.code); // Check if the errors are reference equal. assert(identical.shift() ? err === knownError : err !== knownError); }, 2)); diff --git a/test/parallel/test-promise-unhandled-error.js b/test/parallel/test-promise-unhandled-error.js index 67607dd5ebfa2b..55727267401d4c 100644 --- a/test/parallel/test-promise-unhandled-error.js +++ b/test/parallel/test-promise-unhandled-error.js @@ -46,7 +46,8 @@ process.on('uncaughtException', common.mustCall((err, origin) => { counter.dec(); assert.strictEqual(origin, 'unhandledRejection', err); const knownError = errors.shift(); - assert.deepStrictEqual(err, knownError); + assert.strictEqual(err.message, knownError.message); + assert.strictEqual(err.code, knownError.code); // Check if the errors are reference equal. assert(identical.shift() ? err === knownError : err !== knownError); }, 2)); diff --git a/test/parallel/test-promise-unhandled-issue-43655.js b/test/parallel/test-promise-unhandled-issue-43655.js new file mode 100644 index 00000000000000..4fd2c1a711d5a5 --- /dev/null +++ b/test/parallel/test-promise-unhandled-issue-43655.js @@ -0,0 +1,27 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); + +function delay(time) { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +} + +async function test() { + for (let i = 0; i < 100000; i++) { + await new Promise((resolve, reject) => { + reject('value'); + }) + .then(() => { }, () => { }); + } + + const time0 = Date.now(); + await delay(0); + + const diff = Date.now() - time0; + assert.ok(Date.now() - time0 < 500, `Expected less than 500ms, got ${diff}ms`); +} + +test(); diff --git a/test/parallel/test-promise-unhandled-throw.js b/test/parallel/test-promise-unhandled-throw.js index b9e57fb8bff412..0c5228d810c2c6 100644 --- a/test/parallel/test-promise-unhandled-throw.js +++ b/test/parallel/test-promise-unhandled-throw.js @@ -47,7 +47,8 @@ process.on('uncaughtException', common.mustCall((err, origin) => { counter.dec(); assert.strictEqual(origin, 'unhandledRejection', err); const knownError = errors.shift(); - assert.deepStrictEqual(err, knownError); + assert.strictEqual(err.message, knownError.message); + assert.strictEqual(err.code, knownError.code); // Check if the errors are reference equal. assert(identical.shift() ? err === knownError : err !== knownError); }, 2));