Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

fix($rootScope): fix potential memory leak when removing scope listeners (v2) #16293

Merged
merged 1 commit into from
Nov 1, 2017

Conversation

jbedard
Copy link
Collaborator

@jbedard jbedard commented Oct 21, 2017

This is the alternative to #16161 that we discussed.

This simplifies the listener cleanup and avoids creating sparse listener arrays (unlike #16161) but introduces a new restriction that disallows recursive event $broadcast/$emit-ing. We could remove this restriction but it would complicate everything event related by requiring a stack for the index.

Copy link
Member

@gkalpak gkalpak left a comment

Choose a reason for hiding this comment

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

A couple of minor comments.
LGTM as soon as Travis is happy 😉

var listeners = scope.$$listeners[name];
if (listeners) {
if (listeners.$$index !== -1) {
throw $rootScopeMinErr('inevt', '{0} already $emit/$broadcast-ing', name);
Copy link
Member

Choose a reason for hiding this comment

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

Maybe worth mentioning the scope $id (or even whether it was being $emited/$broadcasted) to aid in debugging).

Also, we need an error page for $rootScope:inevt.

//allow all listeners attached to the current scope to run
listeners[listeners.$$index].apply(null, listenerArgs);
} catch (e) {
$exceptionHandler(e);
Copy link
Member

Choose a reason for hiding this comment

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

If $exceptionHandler throws, listeners.$$index will be never reset and the event will be never allowed to be emitted/broadcasted any more. I wonder if that would break some usecase unnecessarily.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah I'd like to fix this somehow...

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you just wrap the loop in a try...finally and put the listerners.$$index = -1 statement in the finally block?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added the try...finally and added a test that fails without that fix.



it('should call next listener when removing current', inject(function($rootScope) {
var listener1 = jasmine.createSpy().and.callFake(function() { remove1(); });
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Giving spies a name makes it a little easier to identify the error (in case the test breaks).

Copy link
Contributor

Choose a reason for hiding this comment

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

Note: We are relying upon scope hoisting of the var remove1 here.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we felt that is bad, one way around this could be:

var listener1 = jasmine.createSpy();
var remove1 = $rootScope.$on('abc', listener1);
listener1.and.callFake(function() { remove1(); };

var remove2 = $rootScope.$on('abc', listener2);

var listener3 = jasmine.createSpy();
var remove3 = $rootScope.$on('abc', listener3);
Copy link
Member

Choose a reason for hiding this comment

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

It might be worth adding a 4th listener to make sure the rest of the queue is not messed up.

decrementListenerCount(self, 1, name);
if (index <= namedListeners.$$index) {
namedListeners.$$index--;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

A comment here explaining the logic will help in the future. E.g. "we are removing a listener while iterating over the list of listeners. Update the current $index if necessary to ensure no listener is skipped."

@@ -2453,7 +2579,7 @@ describe('Scope', function() {
expect(spy1).toHaveBeenCalledOnce();
expect(spy2).toHaveBeenCalledOnce();
expect(spy3).toHaveBeenCalledOnce();
expect(child.$$listeners['evt'].length).toBe(3); // cleanup will happen on next $emit
expect(child.$$listeners['evt'].length).toBe(2);
Copy link
Contributor

Choose a reason for hiding this comment

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

🥇

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah I like this too 👍

}));


it('should call next listener when removing current', inject(function($rootScope) {
Copy link
Contributor

Choose a reason for hiding this comment

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

should not skip next listener when removing current one?

Copy link
Contributor

Choose a reason for hiding this comment

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

or not to be negative:

should call next listener after removing the current listener via its own handler

}));


it('should call all listeners when removing previous', inject(function($rootScope) {
Copy link
Contributor

Choose a reason for hiding this comment

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

should call all subsequent listeners when a previous listener is removed via a handler

expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing');
}));


Copy link
Contributor

Choose a reason for hiding this comment

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

Is it worth testing cyclic recursion to? E.g. handler for event "a" broadcasts event "b", whose hander broadcasts event "a" again?

@jbedard
Copy link
Collaborator Author

jbedard commented Oct 28, 2017

#16161 was merged to v1.6.x and I cherry-picked the test commit from there into master (c15c8a1). So this PR no longer has that that commit.

For this fix... I've added the error docs. Please let me know if they need more info or more examples etc.

var listeners = scope.$$listeners[name];
if (listeners) {
if (listeners.$$index !== -1) {
throw $rootScopeMinErr('inevt', '{0} already $emit/$broadcast-ing on scope ({1})', name, scope.$id);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added the scope.$id here.

Also any better ideas for the error code inevt?

jbedard added a commit to jbedard/angular.js that referenced this pull request Oct 28, 2017
Previously the array entry for listeners was set to null but the array size was
not trimmed until the event was broadcasted again (see e6966e0).

By keeping track of the listener iteration index globally it can be adjusted if
a listener removal effects the index.

Fixes angular#16135
Closes angular#16293

BREAKING CHANGE:

Recursively invoking `$emit` or `$broadcast` with the same event name is
no longer supported. This will now throw a `inevt` minErr.
@@ -1167,7 +1167,7 @@ function $RootScopeProvider() {
$on: function(name, listener) {
var namedListeners = this.$$listeners[name];
if (!namedListeners) {
this.$$listeners[name] = namedListeners = [];
this.$$listeners[name] = namedListeners = extend([], {$$index: -1});
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think I'll drop this line and use undefined instead of -1 as the "not looping right now" value

Copy link
Contributor

Choose a reason for hiding this comment

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

+1

Previously the array entry for listeners was set to null but the array size was
not trimmed until the event was broadcasted again (see e6966e0).

By keeping track of the listener iteration index globally it can be adjusted if
a listener removal effects the index.

Fixes angular#16135
Closes angular#16293

BREAKING CHANGE:

Recursively invoking `$emit` or `$broadcast` with the same event name is
no longer supported. This will now throw a `inevt` minErr.
@jbedard
Copy link
Collaborator Author

jbedard commented Oct 31, 2017

Updated. I think this is ready...

}
}
invokeListeners(scope, event, listenerArgs, name);

//if any listener on the current scope stops propagation, prevent bubbling
if (stopPropagation) {
event.currentScope = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

not related to this PR but why not just do break; here? Since the two lines here are identical to those directly after the loop.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That was bugging me too. I actually originally made that change in this PR but reverted since it isn't related. I'll probably do that once this PR merges...

if (listeners) {
if (listeners.$$index !== undefined) {
throw $rootScopeMinErr('inevt', '{0} already $emit/$broadcast-ing on scope ({1})', name, scope.$id);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't the try...finally block wrap this if block too?
Otherwise the listeners.$$index doesn't get cleaned up if the inevt error is thrown.

Copy link
Contributor

Choose a reason for hiding this comment

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

Or will the original emit/broadcast catch it in its own try...finally?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The original should reset it in its own try...finally

Copy link
Contributor

@petebacondarwin petebacondarwin left a comment

Choose a reason for hiding this comment

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

Couple of questions

@jbedard jbedard merged commit 817ac56 into angular:master Nov 1, 2017
@christopherthielen
Copy link

christopherthielen commented Dec 4, 2017

This change breaks a common use case (redirects) for ui-router.

The user wires a $stateChangeStart event listener, checks some conditions, then redirects. To do so, they preventDefault on the event, and start a new transition to a different state, which fires a new $stateChangeStart event.

This all happens in the same digest cycle and triggers the inevt error.

app.run(function($rootScope, $state) {
  $rootScope.$on('$stateChangeStart', function(evt, toState) {
    if (toState.name === 'home.foo' /* e.g., check more conditions */) {
      evt.preventDefault();
      $state.go('other'); // redirect
    }
  })
});

Here's an example: http://plnkr.co/edit/maWQbPvppc0ejlG4QEE2?p=preview

Developers that run into this can work around the problem by wrapping the state.go redirect in a timeout. I understand the reason for this commit, but I'm worried that it will break a lot of apps.

I suspect there might be other libs where this pattern is used (listen for an event, cancel it, fire a new event). I tried writing the same code using ngRoute, but it doesn't show the same behavior somehow.

@jbedard
Copy link
Collaborator Author

jbedard commented Dec 4, 2017

Thanks @christopherthielen, I always thought we'd find a common case like that, but we never did before merging this one :/

If ng-router still works with this pattern I assume it does the navigation+event async from the go call, but I'm not sure.

My hunch is that we'll have to go with the 1.6 version (358a69f) unless we can think of another way to do this... a stack of indexes instead of one index...?

@Narretz
Copy link
Contributor

Narretz commented Dec 4, 2017

I'm also in favor of the 1.6 solution, at least as an immediate reaction.

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

Successfully merging this pull request may close these issues.

6 participants