-
Notifications
You must be signed in to change notification settings - Fork 29.6k
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
Conversation
lib/events.js
Outdated
'options', 'object'); | ||
} | ||
// Do not pull these in at the top, they have to be lazy evaluated | ||
const { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the best/fastest way to pull these in (calling process.binding()
every time)? What about making these top-level variables and adding a check if one of them is undefined
and then set them from process.binding('util')
?
lib/events.js
Outdated
resolved = true; | ||
this.removeListener(type, listener); | ||
promiseResolve(promise, { emitter: this, args }); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing semicolon here and below for cancel
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doh lol
doc/api/events.md
Outdated
@@ -586,6 +586,53 @@ to indicate an unlimited number of listeners. | |||
|
|||
Returns a reference to the `EventEmitter`, so that calls can be chained. | |||
|
|||
### async emitter.when(eventName[, options]) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 :-)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
doc/api/events.md
Outdated
* `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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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');
There was a problem hiding this comment.
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 ;)
There was a problem hiding this comment.
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 :-)
lib/events.js
Outdated
const cancel = (eventName, removedListener) => { | ||
if (!resolved && eventName === type && removedListener === listener) { | ||
this.removeListener('removeListener', cancel); | ||
promiseReject(promise, 'canceled'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would argue that promise rejections should always be Error
instances. Also, this should definitely contain the information that the Promise was rejected because its listener was removed – right now, it’s quite possible that a full-blown application using this might fail and canceled
is literally all the diagnostic information it shows.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1
I like the concept. A few points to think about:
const promise = ee.when('foo');
ee.removeListener('foo', promise); or to keep the const promise = ee.when('foo');
ee.removeListener('foo', util.callbackify(promise)); |
@refack .. unregistering using the promise instance is difficult given that Promise chaining can easily cause the original promise reference to be lost. For instance, this wouldn't work, because the const promise = ee.when('foo').then(() => {});
ee.removeListener('foo', promise); // doesn't work! |
I don’t think adding a |
} | ||
}); | ||
|
||
myEmitter.removeAllListeners(); |
There was a problem hiding this comment.
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');
There was a problem hiding this comment.
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.
Both ideas are "raw" thoughts, I see how they both can fail to cover all cases. I'm thinking of a case where this is exposed by a third party, and the end-user wants a simple solution that covers the simple solution. Another option is to expose a const promise = thirdPartyApi.whenAyncHandler('foo');
promise.remove(); |
Regarding your item 4, given that a For item 5, I actually consider that a feature. We could potentially make this an ee.when('foo', { rejectOnRemove: false }); |
Add `async emitter.when()` function that returns a Promise resolved with a once handle
@addaleax ... I've removed the prepend option. |
Any idea if timeout option would be useful ? |
Potentially |
Maybe via second argument options object? |
Ping @nodejs/collaborators |
ee.removeListener('foo', fn); | ||
} | ||
|
||
process.on('unhandledRejection', common.mustNotCall()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
common.crashOnUnhandledRejection();
This seems like an interesting feature but I'm afraid users will expect that you can do this: var ee = new EventEmitter();
ee.when('myEvent').then((result) => {
console.log(result);
}).catch((e) => { // catch the "error" event
console.error('error!!!', e);
});
ee.emit('error', new Error('boom')); Should we warn about that in the docs? Also, do you have a concrete example where it would be useful to have this new method? It can be with a userland module. |
As mentioned on twitter. Not a fan and -1. Is this really necessary to have in core? Seems fairly simple to do as module, no? My intention would be too keep surface area of the API small for maintenance, docs and bug tracker size reasons. I don't have strong feelings though. |
Would appreciate pings on these issues in the future too :) |
some pseudocode: async function readFully(url) {
var req = http.request({...});
var buffer = ...
req.on('data', (chunk) => buffer.concat(chunk));
await req.wait('end');
return buffer;
} Basically, anywhere an event emitter has an end - which is streams. |
I was thinking about this and the use of |
@refack Domenic has requested to not be pinged in this repository. (Not An Error is a really good read). Cancellable promises is a rescinded proposal after consensus couldn't be reached on it. I think we should do what we did with I'm more concerned by @targos's example in: ee.when('myEvent').then((result) => {
console.log(result);
}).catch((e) => { // catch the "error" event
console.error('error!!!', e);
});
ee.emit('error', new Error('boom')); People aren't great at realizing that promises are a one time thing. I think this means the naming needs to be clearer. Some bikeshed:
|
@benjamingr, but there is the whole cancel concern... so: async function* readFully(url) {
var req = http.request({...});
var buffer = ...
req.on('data', (chunk) => buffer.concat(chunk));
try {
await req.wait('end');
} catch (r) {
if (r.code !== 'ERR_EVENTS_WHEN_CANCELED') throw e;
}
return buffer;
} |
@refack that's a good point, in practice "promisifying" streams is a little harder. I think it makes sense for |
See let alone those discussions make me wanna reject this. |
@eljefedelrodeodeljefe I tend to agree, but no one suggested merging it yet - there is a good chance there are good solutions to all these problems we're discussing. This is the first time we're really talking about adding a promise based API to Node.js and this is a really tricky one. |
If you don't add an implicit error event handler, the user would have to do things like: await Promise.race([
req.wait('end'),
req.wait('error').then((r) => {throw r.args[0];})
]); That's not very nice :( |
Before landing How about we release this as module on npm, do a couple of blog posts, see if the ecosystem likes it and then decides? We can figure out the details on how to do these easily on npm-land, while once it lands here it is very hard to change. How about: const oncep = require('oncep')
oncep(ee, 'myevent').then(...).catch(...) |
Closing given the feedback. |
The DOM actually does this (waitUntil I think?), and people have asked us dozens of times (on StackOverflow and on GH) how to promisify event emitters. |
@benjamingr oh I did not know. Let's work out a solution and test it in the ecosystem before landing here. There are so many edge cases it might take several iterations to do properly. |
@mcollina I agree, I think this is pretty hard. To be honest I think that once async iterators land and are optimized they provide a nicer alternative to what this does:
Since async iterators are compatible with consuming streams and the DOM already uses them. It would be nice to do something like: async function once(stream) {
for await(const item of stream) return item;
} |
Thank you for the discourse. |
@benjamingr I don't think AsyncIterators are a good solution for this. AsyncIterator needs backpressure support to avoid memory issues (like streams). |
Is the await for each item not sufficient to provide backpressure? |
@Qard no. EventEmitter has no concept of control flow and buffering, in fact it's what we use to implement those. |
Wouldn't readable+read work? I'm a bit detached from the goings on of core streams work, but I wrote my own userland thing that seems to work fine with readable+read. |
Yes because of the semantics of read(). That is not part of EE. |
Not sure what EE has to do with AsyncIterator backpressure. I think I'm missing something. 😕 |
@Qard I am referring to the example in #15204 (comment) (the first one) |
@mcollina in genera you're right but returning from a for await loop closes the iterator which should cause it to buffer at most one item before not listening to items anymore (or am I misunderstanding?) |
Oh never mind, you're referring to the first example. A correct way to do it would be to call .return on the async iterator after the next completes. For... await is much nicer here. |
Regarding the For..await loop, the problem is that the iterator cannot stop the events being emitted. So, if you are nesting another await call, you will be filling up memory. Streams are built to handle that case, and it is good to add AsyncIterator there. Because Streams are EventEmitters, it does not feel correct that the behavior is in both and it might create some contention. |
You mean for a general event emitter case? In that case I apologize for misunderstanding and agree.
I'll see if I can get people to use async iterators with streams (and the package from the issue) when I get back from traveling (Mid October) to see if I can get more data on what works. |
@benjamingr we are in agreement. |
Well WHATWG landed AbortController whatwg/fetch#523: const controller = new AbortController();
const signal = controller.signal;
setTimeout(() => controller.abort(), 5000);
fetch(url, { signal }).then(response => {
return response.text();
}).then(text => {
console.log(text);
}).catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Uh oh, an error!', err);
}
}); |
Add
async emitter.when()
function that returns a Promise resolved with a one time handler/cc @addaleax
Checklist
make -j4 test
(UNIX), orvcbuild test
(Windows) passesAffected core subsystem(s)
events