-
Notifications
You must be signed in to change notification settings - Fork 27.4k
fix($rootScope): fix potential memory leak when removing scope listeners (v2) #16293
Conversation
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.
A couple of minor comments.
LGTM as soon as Travis is happy 😉
src/ng/rootScope.js
Outdated
var listeners = scope.$$listeners[name]; | ||
if (listeners) { | ||
if (listeners.$$index !== -1) { | ||
throw $rootScopeMinErr('inevt', '{0} already $emit/$broadcast-ing', name); |
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.
Maybe worth mentioning the scope $id
(or even whether it was being $emit
ed/$broadcast
ed) to aid in debugging).
Also, we need an error page for $rootScope:inevt
.
src/ng/rootScope.js
Outdated
//allow all listeners attached to the current scope to run | ||
listeners[listeners.$$index].apply(null, listenerArgs); | ||
} catch (e) { | ||
$exceptionHandler(e); |
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 $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.
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.
Yeah I'd like to fix this somehow...
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.
Could you just wrap the loop in a try...finally
and put the listerners.$$index = -1
statement in the finally block?
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 added the try...finally
and added a test that fails without that fix.
test/ng/rootScopeSpec.js
Outdated
|
||
|
||
it('should call next listener when removing current', inject(function($rootScope) { | ||
var listener1 = jasmine.createSpy().and.callFake(function() { remove1(); }); |
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.
Nit: Giving spies a name makes it a little easier to identify the error (in case the test breaks).
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.
Note: We are relying upon scope hoisting of the var remove1
here.
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 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(); };
test/ng/rootScopeSpec.js
Outdated
var remove2 = $rootScope.$on('abc', listener2); | ||
|
||
var listener3 = jasmine.createSpy(); | ||
var remove3 = $rootScope.$on('abc', listener3); |
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 might be worth adding a 4th listener to make sure the rest of the queue is not messed up.
src/ng/rootScope.js
Outdated
decrementListenerCount(self, 1, name); | ||
if (index <= namedListeners.$$index) { | ||
namedListeners.$$index--; | ||
} |
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.
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); |
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.
🥇
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.
Yeah I like this too 👍
test/ng/rootScopeSpec.js
Outdated
})); | ||
|
||
|
||
it('should call next listener when removing current', inject(function($rootScope) { |
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.
should not skip next listener when removing current one
?
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.
or not to be negative:
should call next listener after removing the current listener via its own handler
test/ng/rootScopeSpec.js
Outdated
})); | ||
|
||
|
||
it('should call all listeners when removing previous', inject(function($rootScope) { |
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.
should call all subsequent listeners when a previous listener is removed via a handler
test/ng/rootScopeSpec.js
Outdated
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing'); | ||
})); | ||
|
||
|
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 it worth testing cyclic recursion to? E.g. handler for event "a" broadcasts event "b", whose hander broadcasts event "a" again?
19abca5
to
2c74082
Compare
src/ng/rootScope.js
Outdated
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); |
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 added the scope.$id
here.
Also any better ideas for the error code inevt
?
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.
src/ng/rootScope.js
Outdated
@@ -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}); |
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 think I'll drop this line and use undefined
instead of -1
as the "not looping right now" value
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
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.
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; |
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.
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.
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.
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); | ||
} |
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.
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.
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.
Or will the original emit/broadcast catch it in its own try...finally
?
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.
The original should reset it in its own try...finally
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.
Couple of questions
This change breaks a common use case (redirects) for ui-router. The user wires a This all happens in the same digest cycle and triggers the 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 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. |
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 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...? |
I'm also in favor of the 1.6 solution, at least as an immediate reaction. |
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.