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

Implement listener subscription as async iterator protocol #20

Closed
wants to merge 25 commits into from
Closed

Implement listener subscription as async iterator protocol #20

wants to merge 25 commits into from

Conversation

lorenzofox3
Copy link
Contributor

Note:

  • I fail to run xo (it seems the parser used does not understand await-for-of statement)
  • tests are implemented but I am not able to run them. I have been passing some hours trying to configure ava with babel without success. Please help me! However the code "manually" works in implementing engines (see jsfiddle with chrome 63 or firefox 57)

@sindresorhus
Copy link
Owner

I fail to run xo (it seems the parser used does not understand await-for-of statement)

Just npm i -D babel-eslint and add the following to the bottom of package.json:

"xo": {
	"parser": "babel-eslint"
}

tests are implemented but I am not able to run them.

Try adding the following below babel.plugins in package.json:

"presets": [
	"@ava/stage-4"
]

@lorenzofox3
Copy link
Contributor Author

Great ! thanks. Still unable to run with a for await statement though. However it seems to be yet another issue with the transpiled code:

  • no issue when manually iterating over the iterator (through next method)
  • error says [Symbol.iterator] is undefined whereas I am pretty sure there is no issue on supporting platforms (see jsFiddle) so I believe something is wrong with the transpiled code
//in async function context
const iterator = emitter.on('foo');
const {value,done} = await iterator.next(); // This works with transpiled and not transpiled code

//this does not work with transpiled code but works on supporting platforms
for await (let result of emitter.on('foo')){
  //
}

Copy link
Collaborator

@novemberborn novemberborn left a comment

Choose a reason for hiding this comment

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

@lorenzofox3 very neat! I wonder though whether this should be landed before there's a Node.js version that supports asynchronous iterators. Can the iteration protocol be implemented without using async function *?

index.js Outdated
if (queue.length > 0) {
yield queue.shift();
} else {
yield await emitter.once(eventName);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This shouldn't yield, queue will contain the data of the next event after await returns.

Alternatively you could await a new promise, and expose its resolve() method to the listener created above. That way you don't need to add an additional listener.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you are right the yield is superfluous.
However I don't think exposing the resolve of the new promise on each iteration is a good idea: As the listener above is created once I would need to create a binding in its closure and reassign this binding to the resolve of the new promise on each iteration. That seems a bit awkward and unsafe, or I am missing something.

index.js Outdated
this._getListeners(eventName).add(listener);
return this.off.bind(this, eventName, listener);

if (typeof listener === 'function') {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this check the number of arguments passed, rather than the listener type? It seems a little too easy to accidentally create an asynchronous iterator otherwise.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As you wish. Although it is a public method with a specific contract so I think this alternative is better. Otherwise it means I'll need to check for "arguments" which is not correct in strict mode; Or use the spread operator on the signature which makes the contract less obvious in my opinion

@dinoboff
Copy link
Contributor

dinoboff commented Jan 3, 2018

You can use node 9 with --harmony flag or node 10 nightly.

For transpiler, I think TS has better async-iterator support than babel; it had the last time I checked.

@dinoboff
Copy link
Contributor

dinoboff commented Jan 3, 2018

Until better support for async-iterator, it would be better to set it up as a helper in an other module, e.g. emittery/iterator.js, or an other package. It would either monkey patch / extend Emittery or be function taking an event emitter and an event name.

@lorenzofox3
Copy link
Contributor Author

lorenzofox3 commented Jan 3, 2018

I have updated to pull request so the async iterator syntax behaviour has its own file.
Note I am still having trouble running the test here:

  • all tests run for legacy.js and main.js
  • I have added a npm run test:next (for nodejs 9 >) to run the tests without any transpilation (on modern engines). However ava stills transpiles and fails as the default preset does not understand for await statement. I have tried to add an ava configuration to bypass transpilation as suggested in the documentation. However this configuration becomes invalid for the regular case (npm test). Any Idea ?

@novemberborn
Copy link
Collaborator

novemberborn commented Jan 4, 2018

@lorenzofox3 I've been playing with this a bit. I hope you don't mind if I push my commits onto your branch.

Async iterators can be implemented without async function *, see https://jakearchibald.com/2017/async-iterators-and-generators/#a-shorter-implementation. This means we don't need to transpile the source. We can return an async iterator that is usable even on Node.js versions that don't have for-await-of.

babel-plugin-transform-async-generator-functions still requires Symbol.asyncIterator to exist. Perhaps this caused your test failure?

I think we still need to resolve:

  • How do we guard against emitter.on('event', myUndefinedListener) accidentally returning an iterator and its associated memory leaks? Perhaps the iterator doesn't actually subscribe until next() is called?
  • Should onAny() return an iterator?
  • Should breaking out of the for-await-of unsubscribe the iterator?
  • Need to update the TypeScript definition and the README

@lorenzofox3
Copy link
Contributor Author

@lorenzofox3 I've been playing with this a bit. I hope you don't mind if I push my commits onto your branch.

Don't worry it is all good !

Async iterators can be implemented without async function *, see https://jakearchibald.com/2017/async-iterators-and-generators/#a-shorter-implementation. This means we don't need to transpile the source. We can return an async iterator that is usable even on Node.js versions that don't have for-await-of.

Agree but the whole point if I understood correctly was to get the syntactic sugar of the for-await-of .
So

  1. People who want to use it now will likely have support on their platform and does not need to transpile anyway. (That's why I was quite enthusiastic with the different files/packages approach)
  2. Support will soon be quite wide.
  3. I feel generators are more appropriate for such use cases (implementation is easier to read than going with the low level interface of the iterator protocol where you need to be careful to release resources etc.) - see Reginald Braithwaite article. Of course that is only a matter of preferences and I will let you make the choice.

babel-plugin-transform-async-generator-functions still requires Symbol.asyncIterator to exist. Perhaps this caused your test failure?

It is more my inability to run AvA with different configurations, I wanted to:

  • run the main tests with the default settings (default Emittery implmentation)
  • run natively, with no transpilation, on node 9 --harmony the tests for the async iterators implementation. This requires to set a different configuration so AvA bypass the transpilation. However this configuration becomes invalid for the first use case. Bottom's line: I am not confortable with AvA :)

Regarding your implementation:
I am not a big fan of relying on a listener closure (your flush function) being reassigned on every iteration. See my previous comment:

you are right the yield is superfluous.
However I don't think exposing the resolve of the new promise on each iteration is a good idea: As the listener above is created once I would need to create a binding in its closure and reassign this binding to the resolve of the new promise on each iteration. That seems a bit awkward and unsafe, or I am missing something.

Again that is a matter of preference I guess.

How do we guard against emitter.on('event', myUndefinedListener) accidentally returning an iterator and its associated memory leaks? Perhaps the iterator doesn't actually subscribe until next() is called?

Right I had not thought of an undefined variable. Then, I think another method like for await (const d of emitter.from('event)){} would be better. But your solution seems good too.

Should onAny() return an iterator?

probably.

Should breaking out of the for-await-of unsubscribe the iterator?

yes (if it follows the same semantic than a the for of loop). It will call the the return method of the iterator or the finally clause of the generator (see the link above from Reginald).

index.js Outdated
},
return() {
off();
queue = null;
Copy link
Contributor

@dinoboff dinoboff Jan 4, 2018

Choose a reason for hiding this comment

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

It should be async too, take a value argument and return {done: true, value}.

https://tc39.github.io/proposal-async-iteration/#sec-asynciterator-interface

@novemberborn
Copy link
Collaborator

Agree but the whole point if I understood correctly was to get the syntactic sugar of the for-await-of.

@lorenzofox3 that still works even with the manual implementation. The benefit though is that the code is directly compatible with Node.js 8.

It is more my inability to run AvA with different configurations, I wanted to:

run natively, with no transpilation, on node 9 --harmony the tests for the async iterators implementation. This requires to set a different configuration so AvA bypass the transpilation. However this configuration becomes invalid for the first use case. Bottom's line: I am not confortable with AvA :)

This would be possible by wrapping the babel-plugin-transform-async-generator-functions plugin and not applying it under Node.js 9. It'd be a nice way of ensuring the implementation is compatible with V8 rather than Babel.

I am not a big fan of relying on a listener closure (your flush function) being reassigned on every iteration

Awaiting .once() is more elegant, yes, though we'd have to make sure we don't enqueue the same value in the existing .on() listener. I don't see an elegant way of doing that, which lead me down this path.

I think another method like for await (const d of emitter.from('event)){} would be better.

I do like the idea of using different method names! Especially since these iterators aren't immediately useful on Node.js <= 8. I'm struggling with the name though. I can definitely see Emittery being subclassed a lot, and from seems awfully generic to force upon subclasses.

Sticking with the idea of making the iterator lazy, we could add a .buffer() function which starts buffering events before iteration begins. Perhaps that gives us the best of both worlds.

@sindresorhus
Copy link
Owner

I can definitely see Emittery being subclassed a lot, and from seems awfully generic to force upon subclasses.

.fromEvent('foo') ?

@novemberborn
Copy link
Collaborator

.fromEvent('foo') ?

It's not necessarily clear that returns an iterator, though the same applies to .on('foo').

@sindresorhus
Copy link
Owner

.asyncIteratorForEvent(‘foo’) ?

@dinoboff
Copy link
Contributor

dinoboff commented Jan 6, 2018

.events('foo')?

@novemberborn
Copy link
Collaborator

novemberborn commented Jan 6, 2018

I like .events(eventName)! We could then also add .anyEvent().

Feature-detect for-await-of support, and only transpile this feature
when necessary.

With Node.js 9 you can use the following to run tests with the native
implementation:

```console
$ node --harmony_async_iteration ./node_modules/.bin/ava
```
@novemberborn
Copy link
Collaborator

I've done some more work on this now. I'd like to land #25 so clearListeners() can also signal the iterators that they'll be done once fully consumed.

@novemberborn
Copy link
Collaborator

I'd like to land #25 so clearListeners() can also signal the iterators that they'll be done once fully consumed.

Conceptually this may conflict with regular listener behavior. #26 makes it explicit that emit() calls listeners added before emit() was invoked. clearListeners() wouldn't impact this for regular listeners, however we'd end up with race conditions where the final event may or may not be observed in the asynchronous iterator.

emitSerial() may cause similar race conditions, where the event data is not delivered to the iterator until after a later call to emit(). Perhaps data should be delivered to the iterators synchronously. This is fine since it's only read asynchronously anyway.

@sindresorhus sindresorhus changed the title implement listener subscription as asynchronous iterator protocol Implement listener subscription as async iterator protocol Jan 8, 2018
@novemberborn
Copy link
Collaborator

I'd like to land #25 so clearListeners() can also signal the iterators that they'll be done once fully consumed.

Conceptually this may conflict with regular listener behavior. #26 makes it explicit that emit() calls listeners added before emit() was invoked. clearListeners() wouldn't impact this for regular listeners, however we'd end up with race conditions where the final event may or may not be observed in the asynchronous iterator.

emitSerial() may cause similar race conditions, where the event data is not delivered to the iterator until after a later call to emit(). Perhaps data should be delivered to the iterators synchronously. This is fine since it's only read asynchronously anyway.

This is no longer a concern with the changes in #27. clearListeners() will prevent the listener from being called, no matter if invocations are pending. So when all that lands I'll fix the merge conflicts and implement the signaling from clearListeners() so the asynchronous iterators know they shouldn't expect newer events.

The TS definition for anyEvents() needs to be updated given #28.

@novemberborn
Copy link
Collaborator

emitSerial() may cause similar race conditions, where the event data is not delivered to the iterator until after a later call to emit(). Perhaps data should be delivered to the iterators synchronously. This is fine since it's only read asynchronously anyway.

Actually this does bother me. Will explore synchronously delivering data to the iterators, before other listeners are invoked.

clearListeners() now signals iterators to not expect further items,
without clearing the current queue.

Clarify that clearListeners() also impacts iterators. Similarly include
the number of active iterators in listenerCount().

Enqueue new iterator items synchronously, before listeners are called.
@novemberborn
Copy link
Collaborator

I've resolved the merge conflicts. Iterators are now fed events without using the listener mechanism, given my earlier musings.

@novemberborn
Copy link
Collaborator

@dinoboff does the AsyncIterableIterator usage in the TS definition mean consumers will have to load the esnext.asynciterable library in their projects? Is this an OK thing to require? Or should we output a definition file with AsyncIterableIterator included?

@dinoboff
Copy link
Contributor

@novemberborn I doubt it would be required. I will test it.

@sindresorhus
Copy link
Owner

@dinoboff Did you have a chance to test it? :)

@novemberborn
Copy link
Collaborator

I suspect that even if this is an issue with TS 2.7 it'll soon be moot, since async iterators are now stage 4.

@lorenzofox3
Copy link
Contributor Author

buy the way guys, I have published array like operators on asynchronous iterable ;)

@dinoboff
Copy link
Contributor

dinoboff commented Feb 19, 2018

@novemberborn tries with:

mkdir tmp
cd tmp
npm init -y
npm i typescript
npm i lorenzofox3/emittery

I used:

// index.ts
import Emittery = require('emittery');

const ee = new Emittery();

ee.emit('foo', 'bar');

When compiling with npx tsc index.ts, I get:

node_modules/emittery/Emittery.d.ts(33,30): error TS2304: Cannot find name 'AsyncIterableIterator'.
node_modules/emittery/Emittery.d.ts(81,15): error TS2304: Cannot find name 'AsyncIterableIterator'.
node_modules/emittery/Emittery.d.ts(139,61): error TS2304: Cannot find name 'AsyncIterableIterator'.
node_modules/emittery/Emittery.d.ts(140,54): error TS2304: Cannot find name 'AsyncIterableIterator'.
node_modules/emittery/Emittery.d.ts(147,15): error TS2304: Cannot find name 'AsyncIterableIterator'.

ps: It works with npx tsc index.ts -lib es6,esnext.asynciterable but not with npx tsc index.ts -lib es2018 (might have to wait for ts 2.8 to get the correct lib for ES 2018).

@novemberborn
Copy link
Collaborator

might have to wait for ts 2.8 to get the correct lib for ES 2018

I suspect so.

Question then is whether we wait or just document this.

@sindresorhus
Copy link
Owner

sindresorhus commented Feb 19, 2018

I think we should document the behavior and land this PR. When TS 2.8 is out we can just update the docs.

@sindresorhus
Copy link
Owner

@lorenzofox3 Would you be interested in finishing this?

@lorenzofox3
Copy link
Contributor Author

sure. give me until Monday

@lorenzofox3
Copy link
Contributor Author

Actually what do you expect exactly ? If I understand correctly @novemberborn had not merged his continued work due to a typescript issue. So what am I supposed to do ?

I think at this point and regarding the size of library, the age of the PR and the fact it has no dependency, it might be easier to (re)write with modern ES (and maybe directly in typescript as you want type declarations) and let the transpilation to the consumer of the library.

Who is actually using legacy.js ? If one wants to build a legacy application using emittery it will anyway probably transpile it himself to avoid the duplication of the async to generator babel helper shipped within legacy.js ?

I find the set up quite sophisticated and not very dev friendly for a somewhat simple library

@sindresorhus
Copy link
Owner

If I understand correctly @novemberborn had not merged his continued work due to a typescript issue.

The TypeScript thing should not be an issue anymore.

Who is actually using legacy.js ?

legacy.js is gone in master.


I think the remaining work is to fix the merge conflicts and document that the methods that return an async iterator require Node.js 12.

@lorenzofox3
Copy link
Contributor Author

I think the remaining work is to fix the merge conflicts and document that the methods that return an async iterator require Node.js 12.

Actually with the current implementation (ie latest commit on this PR), what requires a given version of node (8.10 with flags or above) is the for await statement (used by the consumer only). Note however that this implementation use arguments which if I am not wrong is not allowed in strict mode (and therefore in type=module script).

However if you want to swap with a more modern implementation (using async generator) we will need to drop the support for node 8 as the code of emittery itself would throw syntax error.

@sindresorhus
Copy link
Owner

Note however that this implementation use arguments which if I am not wrong is not allowed in strict mode (and therefore in type=module script).

You can use ...args instead, or better yet, just explicitly check the arguments.

However if you want to swap with a more modern implementation (using async generator) we will need to drop the support for node 8 as the code of emittery itself would throw syntax error.

I don't want that yet. We can open an issue about it after this PR is merged so we can move to a async generator in the future.

@lorenzofox3
Copy link
Contributor Author

closed in favor of #40 (I had deleted the fork repo)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants