diff --git a/events.js b/events.js index 34b69a0..4ce979d 100644 --- a/events.js +++ b/events.js @@ -50,8 +50,11 @@ var NumberIsNaN = Number.isNaN || function NumberIsNaN(value) { return value !== value; } -function EventEmitter() { - EventEmitter.init.call(this); +var kCapture = typeof Symbol === 'function' ? Symbol('kCapture') : '_fakeSymbol_kCapture'; +var kRejection = typeof Symbol === 'function' && typeof Symbol.for === 'function' ? Symbol.for('nodejs.rejection') : '_fakeSymbol_nodejs.rejection'; + +function EventEmitter(opts) { + EventEmitter.init.call(this, opts); } module.exports = EventEmitter; module.exports.once = once; @@ -59,6 +62,26 @@ module.exports.once = once; // Backwards-compat with node 0.10.x EventEmitter.EventEmitter = EventEmitter; +EventEmitter.captureRejectionSymbol = kRejection; +Object.defineProperty(EventEmitter, 'captureRejections', { + get: function () { + return EventEmitter.prototype[kCapture]; + }, + set: function (value) { + if (typeof value !== 'boolean') { + throw new TypeError('The "EventEmitter.captureRejections" argument must be of type boolean. Received type ' + typeof value); + } + EventEmitter.prototype[kCapture] = value; + }, + enumerable: true +}); + +Object.defineProperty(EventEmitter.prototype, kCapture, { + value: false, + writable: true, + enumerable: false +}); + EventEmitter.prototype._events = undefined; EventEmitter.prototype._eventsCount = 0; EventEmitter.prototype._maxListeners = undefined; @@ -86,7 +109,7 @@ Object.defineProperty(EventEmitter, 'defaultMaxListeners', { } }); -EventEmitter.init = function() { +EventEmitter.init = function init(opts) { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { @@ -95,8 +118,61 @@ EventEmitter.init = function() { } this._maxListeners = this._maxListeners || undefined; + + if (opts && opts.captureRejections) { + if (typeof opts.captureRejections !== 'boolean') { + throw new TypeError('The "options.captureRejections" argument must be of type boolean. Received type ' + typeof opts.captureRejections); + } + this[kCapture] = Boolean(opts.captureRejections); + } else { + this[kCapture] = EventEmitter.prototype[kCapture]; + } }; +var ProcessNextTick = typeof queueMicrotask === 'function' + ? queueMicrotask + : typeof setImmediate === 'function' + ? setImmediate + : setTimeout; + +function addCatch(that, promise, type, args) { + if (!that[kCapture]) { + return; + } + + // Handle Promises/A+ spec, then could be a getter + // that throws on second use. + try { + var then = promise.then; + if (typeof then === 'function') { + then.call(promise, undefined, function(err) { + ProcessNextTick(function () { + emitUnhandledRejectionOrErr(that, err, type, args); + }); + }); + } + } catch (err) { + that.emit('error', err); + } +} + +function emitUnhandledRejectionOrErr(ee, err, type, args) { + if (typeof ee[kRejection] === 'function') { + ee[kRejection].apply(ee, [err, type].concat(args)); + } else { + // We have to disable the capture rejections mechanism, otherwise + // we might end up in an infinite loop. + var prev = ee[kCapture]; + + try { + ee[kCapture] = false; + ee.emit('error', err); + } finally { + ee[kCapture] = prev; + } + } +} + // Obviously not all Emitters should be limited to 10. This function allows // that to be increased. Set to zero for unlimited. EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) { @@ -150,12 +226,29 @@ EventEmitter.prototype.emit = function emit(type) { return false; if (typeof handler === 'function') { - ReflectApply(handler, this, args); + var result = ReflectApply(handler, this, args); + + // We check if result is undefined first because that + // is the most common case so we do not pay any perf + // penalty + if (result !== undefined && result !== null) { + addCatch(this, result, type, args); + } } else { var len = handler.length; var listeners = arrayClone(handler, len); - for (var i = 0; i < len; ++i) - ReflectApply(listeners[i], this, args); + for (var i = 0; i < len; ++i) { + var result = ReflectApply(listeners[i], this, args); + + // We check if result is undefined first because that + // is the most common case so we do not pay any perf + // penalty. + // This code is duplicated because extracting it away + // would make it non-inlineable. + if (result !== undefined && result !== null) { + addCatch(this, result, type, args); + } + } } return true; diff --git a/tests/capture-rejections.js b/tests/capture-rejections.js new file mode 100644 index 0000000..def928d --- /dev/null +++ b/tests/capture-rejections.js @@ -0,0 +1,321 @@ +'use strict'; +var common = require('./common'); +var assert = require('assert'); +var EventEmitter = require('../').EventEmitter; +var captureRejectionSymbol = require('../').captureRejectionSymbol; +var inherits = require('util').inherits; +var hasSymbols = require('has-symbols'); + +var majorVersion = parseInt(process.version.split('.')[0].replace(/^v/, ''), 10); +var hasGlobalUnhandledRejectionHandler = majorVersion >= 1; + +var resolve; +var p = new Promise(function (_resolve) { + resolve = _resolve; +}); + +// Inherits from EE without a call to the +// parent constructor. +function NoConstructor() { +} + +inherits(NoConstructor, EventEmitter); + +function captureRejections() { + var ee = new EventEmitter({ captureRejections: true }); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err); + })); + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + process.nextTick(captureRejectionsTwoHandlers); + })); + ee.emit('something'); +} + +function captureRejectionsTwoHandlers() { + var ee = new EventEmitter({ captureRejections: true }); + var _err = new Error('kaboom'); + + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err); + })); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err); + })); + + var count = 0; + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + if (++count === 2) { + process.nextTick(defaultValue); + } + }, 2)); + + ee.emit('something'); +} + +function defaultValue() { + // Browsers treat unhandled rejections differently from Node.js, + // so we cannot test the Node.js behaviour there. + if (process.browser || !hasGlobalUnhandledRejectionHandler) { + process.nextTick(globalSetting); + return; + } + + var ee = new EventEmitter(); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err); + })); + + process.removeAllListeners('unhandledRejection'); + + process.once('unhandledRejection', common.mustCall(function (err) { + // restore default + process.on('unhandledRejection', function (err) { throw err; }); + + assert.strictEqual(err, _err); + process.nextTick(globalSetting); + })); + + ee.emit('something'); +} + +function globalSetting() { + assert.strictEqual(EventEmitter.captureRejections, false); + EventEmitter.captureRejections = true; + var ee = new EventEmitter(); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err); + })); + + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + + // restore default + EventEmitter.captureRejections = false; + process.nextTick(configurable); + })); + + ee.emit('something'); +} + +// We need to be able to configure this for streams, as we would +// like to call destro(err) there. +function configurable() { + var ee = new EventEmitter({ captureRejections: true }); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (a, b) { + assert.strictEqual(a, 42); + assert.strictEqual(b, 'foobar'); + return Promise.reject(_err); + })); + + if (hasSymbols()) { + assert.strictEqual(captureRejectionSymbol, Symbol.for('nodejs.rejection')); + } + + ee[captureRejectionSymbol] = common.mustCall(function (err, type, a, b) { + assert.strictEqual(err, _err); + assert.strictEqual(type, 'something'); + assert.strictEqual(a, 42); + assert.strictEqual(b, 'foobar'); + process.nextTick(globalSettingNoConstructor); + }); + + ee.emit('something', 42, 'foobar'); +} + +function globalSettingNoConstructor() { + assert.strictEqual(EventEmitter.captureRejections, false); + EventEmitter.captureRejections = true; + var ee = new NoConstructor(); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err); + })); + + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + + // restore default + EventEmitter.captureRejections = false; + process.nextTick(thenable); + })); + + ee.emit('something'); +} + +function thenable() { + var ee = new EventEmitter({ captureRejections: true }); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (value) { + var obj = {}; + + Object.defineProperty(obj, 'then', { + get: common.mustCall(function () { + return common.mustCall(function (resolved, rejected) { + assert.strictEqual(resolved, undefined); + rejected(_err); + }); + }, 1) // Only 1 call for Promises/A+ compat. + }); + + return obj; + })); + + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + process.nextTick(avoidLoopOnRejection); + })); + + ee.emit('something'); +} + +function avoidLoopOnRejection() { + // Browsers treat unhandled rejections differently from Node.js, + // so we cannot test the Node.js behaviour there. + if (process.browser || !hasGlobalUnhandledRejectionHandler) { + process.nextTick(avoidLoopOnError); + return; + } + + var ee = new EventEmitter({ captureRejections: true }); + var _err1 = new Error('kaboom'); + var _err2 = new Error('kaboom2'); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err1); + })); + + ee[captureRejectionSymbol] = common.mustCall(function (err) { + assert.strictEqual(err, _err1); + return Promise.reject(_err2); + }); + + process.removeAllListeners('unhandledRejection'); + + process.once('unhandledRejection', common.mustCall(function (err) { + // restore default + process.on('unhandledRejection', function (err) { throw err; }); + + assert.strictEqual(err, _err2); + process.nextTick(avoidLoopOnError); + })); + + ee.emit('something'); +} + +function avoidLoopOnError() { + // Browsers treat unhandled rejections differently from Node.js, + // so we cannot test the Node.js behaviour there. + if (process.browser || !hasGlobalUnhandledRejectionHandler) { + process.nextTick(thenableThatThrows); + return; + } + + var ee = new EventEmitter({ captureRejections: true }); + var _err1 = new Error('kaboom'); + var _err2 = new Error('kaboom2'); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(_err1); + })); + + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err1); + return Promise.reject(_err2); + })); + + process.removeAllListeners('unhandledRejection'); + + process.once('unhandledRejection', common.mustCall(function (err) { + // restore default + process.on('unhandledRejection', function (err) { throw err; }); + + assert.strictEqual(err, _err2); + process.nextTick(thenableThatThrows); + })); + + ee.emit('something'); +} + +function thenableThatThrows() { + var ee = new EventEmitter({ captureRejections: true }); + var _err = new Error('kaboom'); + ee.on('something', common.mustCall(function (value) { + var obj = {}; + + Object.defineProperty(obj, 'then', { + get: common.mustCall(function () { + throw _err; + }, 1) // Only 1 call for Promises/A+ compat. + }); + + return obj; + })); + + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + process.nextTick(resetCaptureOnThrowInError); + })); + + ee.emit('something'); +} + +function resetCaptureOnThrowInError() { + // Browsers treat unhandled rejections differently from Node.js, + // so we cannot test the Node.js behaviour there. + if (process.browser || !hasGlobalUnhandledRejectionHandler) { + process.nextTick(argValidation); + return; + } + + var ee = new EventEmitter({ captureRejections: true }); + ee.on('something', common.mustCall(function (value) { + return Promise.reject(new Error('kaboom')); + })); + + ee.once('error', common.mustCall(function (err) { + throw err; + })); + + process.removeAllListeners('uncaughtException'); + + process.once('uncaughtException', common.mustCall(function (err) { + process.nextTick(next); + })); + + ee.emit('something'); + + function next() { + process.on('uncaughtException', common.mustNotCall()); + + var _err = new Error('kaboom2'); + ee.on('something2', common.mustCall(function (value) { + return Promise.reject(_err); + })); + + ee.on('error', common.mustCall(function (err) { + assert.strictEqual(err, _err); + + process.removeAllListeners('uncaughtException'); + + // restore default + process.on('uncaughtException', function (err) { throw err; }); + + process.nextTick(argValidation); + })); + + ee.emit('something2'); + } +} + +function argValidation() { + resolve(); +} + +captureRejections(); + +module.exports = p; diff --git a/tests/index.js b/tests/index.js index 2d739e6..9094d0a 100644 --- a/tests/index.js +++ b/tests/index.js @@ -28,6 +28,12 @@ var require = function(file) { }; require('./add-listeners.js'); +if (typeof Promise === 'function') { + require('./capture-rejections.js'); +} else { + // Promise support is not available. + test('./capture-rejections.js', { skip: true }, function () {}); +} require('./check-listener-leaks.js'); require('./errors.js'); require('./events-list.js');