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/removeListener should call on/off #29503

Closed
wants to merge 7 commits into from

Conversation

ronag
Copy link
Member

@ronag ronag commented Sep 9, 2019

Previously these used alias which caused surprising behaviour
when trying to override add/removeListener, e.g. Readable.

This resolves this by making on/off actually call the
corresponding function through the prototype chain.

Checklist
  • make -j4 test (UNIX), or vcbuild test (Windows) passes
  • tests and/or benchmarks are included
  • documentation is changed or added
  • commit message follows commit guidelines

@nodejs-github-bot nodejs-github-bot added the events Issues and PRs related to the events subsystem / EventEmitter. label Sep 9, 2019
@ronag ronag mentioned this pull request Sep 9, 2019
4 tasks
@addaleax
Copy link
Member

addaleax commented Sep 9, 2019

I would leave the documentation as it is, because .on() is still the most common way to attach an event listener to an emitter.

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

@addaleax: Leaving it as is will be wrong though? If you override on it won't actually do what it says. You should always be overriding addListener.

@addaleax
Copy link
Member

addaleax commented Sep 9, 2019

Then maybe we could add notes to the on/off docs that say “This function is implemented by calling addListener()/removeListener()”?

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

Then maybe we could add notes to the on/off docs that say “This function is implemented by calling addListener()/removeListener()”?

Sure.

But currently the documentation says that addListener is an Alias for emitter.on(eventName, listener). Which is strictly untrue. So I'm not sure how to apply your suggestion.

I'm not sure how to keep it close to as it was, and at the same time make it correct.

@benjamingr
Copy link
Member

this is a bit scary compared to making it an alias just for streams. I am fine with this with a citgm run :]

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

This is potentially dangerous. Depending on which one is overriden and how one could end up in infinite recursion.

I'm very unsure whether this is worth landing... alternatively we could update the docs to make sure both are override and maybe add some kind of warning, i.e. if one is override the other must also be overriden?

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

@addaleax I sorted out your suggestion. By making on and removeListener primary (i.e. how they are used in the wild).

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

Can we solve this better using getters/setters or Proxy?

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

@Trott: would you mind adding a WIP on this so it doesn't land.

@benjamingr benjamingr added the wip Issues and PRs that are still a work in progress. label Sep 9, 2019
@Fishrock123
Copy link
Contributor

Does this duplicate the work in #29486?

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

@Fishrock123: Yes, but this has risks while #29486 does not. I think this PR needs significant more thought.

As I wrote above, if we're unlucky we can cause infinite recursion depending on which methods are and are not overriden.

@devsnek
Copy link
Member

devsnek commented Sep 9, 2019

but then you'd notice the range error from the stack overflow and fix it, right?

@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

but then you'd notice the range error from the stack overflow and fix it, right?

Yes, but if we are unlucky it could break stuff in the ecosystem pretty significantly. Is this worth it?�

@devsnek
Copy link
Member

devsnek commented Sep 9, 2019

if you're worried about a specific situation currently in the ecosystem, we can run citgm and gzemnid code searches to see if this will break/interfere.

@ronag ronag force-pushed the fix-events branch 4 times, most recently from 7ed7149 to 30b9bd4 Compare September 9, 2019 19:54
@ronag
Copy link
Member Author

ronag commented Sep 9, 2019

I'm fine with that. Just making sure we are aware of it :). This should be a server-major?

@devsnek: Are you still approving given the latest changes? If so please remove the WIP label.

@devsnek devsnek removed the wip Issues and PRs that are still a work in progress. label Sep 9, 2019
Previously these used alias which caused surprising behaviour
when trying to override add/removeListener, e.g. Readable.

This resolves this by making add/remove actually call the
corresponding function through the prototype chain.
@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

I think it we should have off call removeListener instead. This would greatly reduce the risk of breakage in old modules.

I can see the point. However, the problem there is that it becomes very confusing when you have to override on/removeListener instead of on/off or addListener/removeListener. I can see a lot of future bugs and confusion from that.

Any ideas on how to get the best of both?

@benjamingr
Copy link
Member

An alternative might be to provide a util on events that does something like:

Events.overrideEmitterMethods(myEmitterLike, {
  on(...) { /* override on*/ },
  off(...) { /* override off*/ }
});

And tell people they can use that. We can also add a note to the docs about the historical reasons leading to the events API and the ecosystem.

@mcollina
Copy link
Member

That'd work and it might not be a semver-major change. Essentially if you don't use it, you are bound to do the multiple override yourself.

@benjamingr
Copy link
Member

benjamingr commented Sep 10, 2019

So just making sure - we agree on:

  • Ronag's work here with Matteo's suggestion above
  • People override on/removeListener but don't actually have to do that because we provide a utility method.

Please let me know if I am understanding this correctly.
Cheers and thanks 🙇

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

@benjamingr: Including my work here will be sem-major (in my opinion) even with the utility function.

We could add just the utility as sem-minor.

@benjamingr
Copy link
Member

@ronag that SGTM

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

Utility as separate PR?

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

Fixed to follow @mcollina suggestion, i.e. on/removeListener which seems to be the least breaking version of this.

@devsnek
Copy link
Member

devsnek commented Sep 10, 2019

having a utility is the same problem as having to manually do the alias like the readable pr does. if you just do class X extends EventEmitter { on() {} } you're broken.

Maybe we can check new.target.prototype.on and new.target.prototype.addEventListener and etc and see if they've been overridden?

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

We could do it like streams does, i.e:

class X extends EventEmitter {
  constructor () {
    super({
      on() {},
      off() {}
    })
  }
}

i.e. send the overrides as constructor arguments.

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

EDIT: Not a good idea

A crazy idea that might be non breaking and fixes the issue. Choose the implementation from the deepest prototype chain in the constructor and override with a property.

Something like:

function EventEmitter() {
  // This should only be false the first time an instance
  // of this class is created.
  if (this.on !== this.addListener) {
    const proto = Object.getPrototypeOf(this)
    const [primary, secondary] = distance(proto, 'on') < distance(proto, 'addListener')
      ? ['on', 'addListener']
      : ['addListener', 'on']
    Object.defineProperty(proto, secondary, {
      get () {
        return this[primary]
      },
      set (value) {
        this[primary] = value
      }
    })
  }
}

function distance(proto, name) {
  let n = 0
  while (proto && !proto[name]) {
    n++
    proto = Object.getPrototypeOf(proto)
  }
  return n
}

Not sure of the performance implications though...

EDIT: Updated

@devsnek
Copy link
Member

devsnek commented Sep 10, 2019

@ronag i think you could check for this.on === new.target.prototype.on and such

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

@devsnek: I'm not sure if that is enough, what if the override is one step up the chain?

@ronag
Copy link
Member Author

ronag commented Sep 10, 2019

Here is an alternative (that works) which dynamically patches the prototype: nxtedition#5.

It shouldn't be breaking and no docs update required. However, it's quite a hack and doesn't work if any properties are overriden on the instance or the prototype after construction.

@benjamingr
Copy link
Member

doesn't work if any properties are overriden on the instance or the prototype after construction.

Unfortunately I believe that is quite common :/

@devsnek

having a utility is the same problem as having to manually do the alias like the readable pr does. if you just do class X extends EventEmitter { on() {} } you're broken.

That's true but note that the issue a utility would solve is not about incorrectly extending eventemitter and not overriding stuff like off - it is to provide a sanctioned way to override EventEmitter that doesn't encourage extending an assymetrical API (on/removeListener)

@mcollina
Copy link
Member

Here is an alternative (that works) which dynamically patches the prototype: nxtedition#5.

I would prefer we had no code to be executed during creation of the EventEmitter, as a lot of implementations in the wild do not call the constructor.

@ronag
Copy link
Member Author

ronag commented Sep 11, 2019

So I guess the current state of this PR is the most favorable so far?

We should probably have overrideEmitterMethods as a separate PR?

@mcollina
Copy link
Member

I'm not really convinced this is the way to go either. I would prefer to document the status quo first.

@jasnell jasnell added the semver-major PRs that contain breaking changes and should be released in the next major version. label Sep 11, 2019
@jasnell
Copy link
Member

jasnell commented Sep 11, 2019

Marking semver-major due to the possibility of breaking things.

@jasnell
Copy link
Member

jasnell commented Sep 11, 2019

Just throwing this in for discussion...

Another potential way to do this would be to introduce the actual implementation behind a symbol function that both on/addListener (and likewise off/removeListener) would defer to. Then, the override could be just for that, but obviously that wouldn't work for any existing overriding out in the ecosystem.

This is just a quick example of what I mean...

const kOn = Symbol('kOn');
const kOff = Symbol('kOff');

EventEmitter.prototype[kOn] = function(event, handler) { / *** / };
EventEmitter.prototype[kOff] = function(event, handler) { / *** / };
EventEmitter.onSymbol = kOn;
EventEmitter.offSymbol = kOff;

EventEmitter.prototype.on = function(event, handler) { return this[kOn](event, handler); };
EventEmitter.prototype.addListener = function(event, handler) { return this[kOn](event, handler); };
EventEmitter.prototype.off = function(event, handler) { return this[kOn](event, handler); };
EventEmitter.prototype.removeListener = function(event, handler) { return this[kOn](event, handler); };


// Then users could override both using
EventEmitter.prototype[EventEmitter.onSymbol] = function...
EventEmitter.prototype[EventEmitter.offSymbol] = function...

Again, just throwing that option in for consideration. Not sure about the overall change.

@benjamingr
Copy link
Member

Do we have any previous research on how event emitter overriding is typically done in userland?

I would assume it's just "people override whatever works and whatever they are using and it's chaotic" but that's just an assumption I am making and I would like to see if we can prove or disprove that.

@ronag
Copy link
Member Author

ronag commented Sep 23, 2019

@Trott I don't think I can make progress on this one. Needs more guidance. Should I leave this open and hope someone picks up on it or close?

@Trott
Copy link
Member

Trott commented Sep 24, 2019

@Trott I don't think I can make progress on this one. Needs more guidance. Should I leave this open and hope someone picks up on it or close?

I don't know that I have a good answer for that. Maybe one of @addaleax @benjamingr @mcollina @devsnek @jasnell @Fishrock123 would have clarity on the best approach.

@ronag
Copy link
Member Author

ronag commented Oct 4, 2019

@Trott: Should I close this for now? Maybe make an issue for it instead?

@ronag ronag closed this Oct 4, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
events Issues and PRs related to the events subsystem / EventEmitter. semver-major PRs that contain breaking changes and should be released in the next major version.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants