Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

events: add async emitter.when #15204

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 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
17 changes: 17 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,23 @@ Used when the native call from `process.cpuUsage` cannot be processed properly.

Used when `c-ares` failed to set the DNS server.

<a id="ERR_EVENTS_WHEN_CANCELED"></a>
### ERR_EVENTS_WHEN_CANCELED

Used when a `Promise` created using `emitter.when()` has been rejected because
the listener has been removed.

```js
const EventEmitter = require('events');

const ee = new EventEmitter();
ee.when('foo').then(() => { /* ... */ }).catch((reason) => {
console.log(reason.code); // ERR_EVENTS_WHEN_CANCELED
});

ee.removeAllListeners('foo');
```

<a id="ERR_FALSY_VALUE_REJECTION"></a>
### ERR_FALSY_VALUE_REJECTION

Expand Down
48 changes: 48 additions & 0 deletions doc/api/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,54 @@ to indicate an unlimited number of listeners.

Returns a reference to the `EventEmitter`, so that calls can be chained.

### async emitter.when(eventName[, options])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's just returning a Promise, do we really need to add the 'async ' prefix here? None of the examples even utilize async/await.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going back and forth on this. It depends on what (if any) convention we want to set on this. I'm not completely set on it and I expected that if anyone didn't like it then they'd speak up :-)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async/await works for any Promise right? If so, I'm not sure we need to explicitly add the prefix in that case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do like the async prefix.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the async prefix too but think that it's important we're consistent with the other method returning promises in the API (util.promisify)

<!-- YAML
added: REPLACEME
-->

* `eventName` {any} The name of the event.
* `options` {Object}
* `prepend` {boolean} True to prepend the handler used to resolve the
`Promise` to the handler queue.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this even make a difference since any handler is called asynchronously anyway?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely not. This is also something I've gone back and forth on so I'm happy to pull this back out if it doesn't make sense

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, This would have an effect when registering multiple when Promises since those would be resolved in a specific order... e.g.

// `a` will always be printed before `b`
ee.when('foo').then(() => console.log('b'));
ee.when('foo', { prepend: true }).then(() => console.log('a'));
ee.emit('foo');
// `b` will always be printed before `a`
ee.when('foo').then(() => console.log('b'));
ee.when('foo', { prepend: false }).then(() => console.log('a'));
ee.emit('foo');

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since those would be resolved in a specific order

Yes, and I would consider relying on promise resolution order a big anti-pattern ;)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I certainly cannot disagree with that :-)

* Returns: {Promise}

Creates and returns a `Promise` that is resolved with a one-time event handler.

```js
const myEmitter = new EventEmitter();

const p = myEmitter.when('foo')
.then((context) => {
console.log(context.args);
});

myEmitter.emit('foo', 1, 2, 3);
```

The `Promise` is resolved with a `context` object that contains two properties:

* `emitter` {EventEmitter} A reference to the `EventEmitter` instance.
* `args` {Array} An array containing the additional arguments passed to the
`emitter.emit()` function.

When a `Promise` is created, a corresponding `removeListener` event handler is
registered that will reject the `Promise` if it has not already been resolved.
When rejected in this manner, the rejection `reason` shall be a simple string
equal to `'canceled'`.

```js
const myEmitter = new EventEmitter();

const p = myEmitter.when('foo')
.catch((reason) => {
if (reason === 'canceled') {
// The promise was canceled using removeListener
}
});

myEmitter.removeAllListeners();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing event name.

myEmitter.removeAllListeners('foo');

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not needed in this case.

```

[`--trace-warnings`]: cli.html#cli_trace_warnings
[`EventEmitter.defaultMaxListeners`]: #events_eventemitter_defaultmaxlisteners
[`domain`]: domain.html
Expand Down
66 changes: 66 additions & 0 deletions lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ EventEmitter.prototype._maxListeners = undefined;
// added to it. This is a useful default which helps finding memory leaks.
var defaultMaxListeners = 10;

var errors;
function lazyErrors() {
if (!errors) {
errors = require('internal/errors');
}
return errors;
}

var createPromise;
var promiseReject;
var promiseResolve;

function lazyPromiseBinding() {
if (!createPromise) {
const util = process.binding('util');
createPromise = util.createPromise;
promiseReject = util.promiseReject;
promiseResolve = util.promiseResolve;
}
}

Object.defineProperty(EventEmitter, 'defaultMaxListeners', {
enumerable: true,
get: function() {
Expand Down Expand Up @@ -351,6 +372,51 @@ EventEmitter.prototype.prependOnceListener =
return this;
};

// An awaitable one-time event listener. Registers a once handler and
// returns an associated promise.
EventEmitter.prototype.when =
function when(type, options = { prepend: false }) {
if (options == null || typeof options !== 'object') {
// This must be lazy evaluated
const errors = lazyErrors();
throw new errors.TypeError('ERR_INVALID_ARG_TYPE',
'options', 'object');
}
lazyPromiseBinding();
const promise = createPromise();
let resolved = false;
const listener = (...args) => {
resolved = true;
this.removeListener(type, listener);
promiseResolve(promise, { emitter: this, args });
};
Object.defineProperties(listener, {
name: { value: `promise for '${type}'` },
promise: { value: promise }
});
// When a listener is removed, and the promise has not yet been
// resolved, reject it with a simple reason.
const cancel = (eventName, removedListener) => {
const errors = lazyErrors();
if (!resolved && eventName === type && removedListener === listener) {
this.removeListener('removeListener', cancel);
promiseReject(promise,
new errors.Error('ERR_EVENTS_WHEN_CANCELED', type));
}
};
// Do not use once because it will trigger a removeListener *before*
// the promise is resolved, which throws off the cancel logic when
// removeListener is called.
if (options.prepend) {
this.prependListener(type, listener);
this.prependListener('removeListener', cancel);
} else {
this.on(type, listener);
this.on('removeListener', cancel);
}
return promise;
};

// Emits a 'removeListener' event if and only if the listener was removed.
EventEmitter.prototype.removeListener =
function removeListener(type, listener) {
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ E('ERR_ENCODING_INVALID_ENCODED_DATA',
(enc) => `The encoded data was not valid for encoding ${enc}`);
E('ERR_ENCODING_NOT_SUPPORTED',
(enc) => `The "${enc}" encoding is not supported`);
E('ERR_EVENTS_WHEN_CANCELED',
(type) => `The when '${type}' promise was canceled`);
E('ERR_FALSY_VALUE_REJECTION', 'Promise was rejected with falsy value');
E('ERR_HTTP_HEADERS_SENT',
'Cannot %s headers after they are sent to the client');
Expand Down
79 changes: 79 additions & 0 deletions test/parallel/test-event-emitter-when.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const EventEmitter = require('events');

const ee = new EventEmitter();

{
ee.when('foo')
.then(common.mustCall((context) => {
assert.strictEqual(context.emitter, ee);
assert.deepStrictEqual(context.args, [1, 2, 3]);
}))
.catch(common.mustNotCall());
assert.strictEqual(ee.listenerCount('foo'), 1);
ee.emit('foo', 1, 2, 3);
assert.strictEqual(ee.listenerCount('foo'), 0);
}

{
let a = 1;
ee.when('foo')
.then(common.mustCall(() => {
assert.strictEqual(a, 2);
}))
.catch(common.mustNotCall());

ee.when('foo', { prepend: true })
.then(common.mustCall(() => {
assert.strictEqual(a++, 1);
}))
.catch(common.mustNotCall());

ee.emit('foo');
}

{
ee.when('foo')
.then(common.mustCall(() => {
throw new Error('foo');
}))
.catch(common.mustCall((err) => {
assert.strictEqual(err.message, 'foo');
}));
assert.strictEqual(ee.listenerCount('foo'), 1);
ee.emit('foo');
assert.strictEqual(ee.listenerCount('foo'), 0);
}

{
ee.removeAllListeners();
ee.when('foo')
.then(common.mustNotCall())
.catch(common.expectsError({
code: 'ERR_EVENTS_WHEN_CANCELED',
type: Error,
message: 'The when \'foo\' promise was canceled'
}));
ee.removeAllListeners();
}

{
ee.removeAllListeners();
assert.strictEqual(ee.listenerCount(0), 0);
const promise = ee.when('foo');
promise.then(common.mustNotCall())
.catch(common.expectsError({
code: 'ERR_EVENTS_WHEN_CANCELED',
type: Error,
message: 'The when \'foo\' promise was canceled'
}));
const fn = ee.listeners('foo')[0];
assert.strictEqual(fn.name, 'promise for \'foo\'');
assert.strictEqual(fn.promise, promise);
ee.removeListener('foo', fn);
}

process.on('unhandledRejection', common.mustNotCall());
Copy link
Member

@targos targos Sep 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common.crashOnUnhandledRejection();