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

Commit 817ac56

Browse files
committed
fix($rootScope): fix potential memory leak when removing scope listeners
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 #16135 Closes #16293 BREAKING CHANGE: Recursively invoking `$emit` or `$broadcast` with the same event name is no longer supported. This will now throw a `inevt` minErr.
1 parent 0864f73 commit 817ac56

File tree

3 files changed

+172
-47
lines changed

3 files changed

+172
-47
lines changed
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@ngdoc error
2+
@name $rootScope:inevt
3+
@fullName Recursive $emit/$broadcast event
4+
@description
5+
6+
This error occurs when the an event is `$emit`ed or `$broadcast`ed recursively on a scope.
7+
8+
For example, when an event listener fires the same event being listened to.
9+
10+
```
11+
$scope.$on('foo', function() {
12+
$scope.$emit('foo');
13+
});
14+
```
15+
16+
Or when a parent element causes indirect recursion.
17+
18+
```
19+
$scope.$on('foo', function() {
20+
$rootScope.$broadcast('foo');
21+
});
22+
```

src/ng/rootScope.js

+34-45
Original file line numberDiff line numberDiff line change
@@ -1181,10 +1181,14 @@ function $RootScopeProvider() {
11811181

11821182
var self = this;
11831183
return function() {
1184-
var indexOfListener = namedListeners.indexOf(listener);
1185-
if (indexOfListener !== -1) {
1186-
namedListeners[indexOfListener] = null;
1184+
var index = arrayRemove(namedListeners, listener);
1185+
if (index >= 0) {
11871186
decrementListenerCount(self, 1, name);
1187+
// We are removing a listener while iterating over the list of listeners.
1188+
// Update the current $$index if necessary to ensure no listener is skipped.
1189+
if (index <= namedListeners.$$index) {
1190+
namedListeners.$$index--;
1191+
}
11881192
}
11891193
};
11901194
},
@@ -1213,9 +1217,7 @@ function $RootScopeProvider() {
12131217
* @return {Object} Event object (see {@link ng.$rootScope.Scope#$on}).
12141218
*/
12151219
$emit: function(name, args) {
1216-
var empty = [],
1217-
namedListeners,
1218-
scope = this,
1220+
var scope = this,
12191221
stopPropagation = false,
12201222
event = {
12211223
name: name,
@@ -1226,28 +1228,11 @@ function $RootScopeProvider() {
12261228
},
12271229
defaultPrevented: false
12281230
},
1229-
listenerArgs = concat([event], arguments, 1),
1230-
i, length;
1231+
listenerArgs = concat([event], arguments, 1);
12311232

12321233
do {
1233-
namedListeners = scope.$$listeners[name] || empty;
1234-
event.currentScope = scope;
1235-
for (i = 0, length = namedListeners.length; i < length; i++) {
1236-
1237-
// if listeners were deregistered, defragment the array
1238-
if (!namedListeners[i]) {
1239-
namedListeners.splice(i, 1);
1240-
i--;
1241-
length--;
1242-
continue;
1243-
}
1244-
try {
1245-
//allow all listeners attached to the current scope to run
1246-
namedListeners[i].apply(null, listenerArgs);
1247-
} catch (e) {
1248-
$exceptionHandler(e);
1249-
}
1250-
}
1234+
invokeListeners(scope, event, listenerArgs, name);
1235+
12511236
//if any listener on the current scope stops propagation, prevent bubbling
12521237
if (stopPropagation) {
12531238
event.currentScope = null;
@@ -1299,28 +1284,11 @@ function $RootScopeProvider() {
12991284

13001285
if (!target.$$listenerCount[name]) return event;
13011286

1302-
var listenerArgs = concat([event], arguments, 1),
1303-
listeners, i, length;
1287+
var listenerArgs = concat([event], arguments, 1);
13041288

13051289
//down while you can, then up and next sibling or up and next sibling until back at root
13061290
while ((current = next)) {
1307-
event.currentScope = current;
1308-
listeners = current.$$listeners[name] || [];
1309-
for (i = 0, length = listeners.length; i < length; i++) {
1310-
// if listeners were deregistered, defragment the array
1311-
if (!listeners[i]) {
1312-
listeners.splice(i, 1);
1313-
i--;
1314-
length--;
1315-
continue;
1316-
}
1317-
1318-
try {
1319-
listeners[i].apply(null, listenerArgs);
1320-
} catch (e) {
1321-
$exceptionHandler(e);
1322-
}
1323-
}
1291+
invokeListeners(current, event, listenerArgs, name);
13241292

13251293
// Insanity Warning: scope depth-first traversal
13261294
// yes, this code is a bit crazy, but it works and we have tests to prove it!
@@ -1350,6 +1318,27 @@ function $RootScopeProvider() {
13501318

13511319
return $rootScope;
13521320

1321+
function invokeListeners(scope, event, listenerArgs, name) {
1322+
var listeners = scope.$$listeners[name];
1323+
if (listeners) {
1324+
if (listeners.$$index !== undefined) {
1325+
throw $rootScopeMinErr('inevt', '{0} already $emit/$broadcast-ing on scope ({1})', name, scope.$id);
1326+
}
1327+
event.currentScope = scope;
1328+
try {
1329+
for (listeners.$$index = 0; listeners.$$index < listeners.length; listeners.$$index++) {
1330+
try {
1331+
//allow all listeners attached to the current scope to run
1332+
listeners[listeners.$$index].apply(null, listenerArgs);
1333+
} catch (e) {
1334+
$exceptionHandler(e);
1335+
}
1336+
}
1337+
} finally {
1338+
listeners.$$index = undefined;
1339+
}
1340+
}
1341+
}
13531342

13541343
function beginPhase(phase) {
13551344
if ($rootScope.$$phase) {

test/ng/rootScopeSpec.js

+116-2
Original file line numberDiff line numberDiff line change
@@ -2316,6 +2316,19 @@ describe('Scope', function() {
23162316
}));
23172317

23182318

2319+
// See issue https://github.com/angular/angular.js/issues/16135
2320+
it('should deallocate the listener array entry', inject(function($rootScope) {
2321+
var remove1 = $rootScope.$on('abc', noop);
2322+
$rootScope.$on('abc', noop);
2323+
2324+
expect($rootScope.$$listeners['abc'].length).toBe(2);
2325+
2326+
remove1();
2327+
2328+
expect($rootScope.$$listeners['abc'].length).toBe(1);
2329+
}));
2330+
2331+
23192332
it('should call next listener after removing the current listener via its own handler', inject(function($rootScope) {
23202333
var listener1 = jasmine.createSpy('listener1').and.callFake(function() { remove1(); });
23212334
var remove1 = $rootScope.$on('abc', listener1);
@@ -2448,6 +2461,107 @@ describe('Scope', function() {
24482461
expect($rootScope.$$listenerCount).toEqual({abc: 1});
24492462
expect(child.$$listenerCount).toEqual({abc: 1});
24502463
}));
2464+
2465+
2466+
it('should throw on recursive $broadcast', inject(function($rootScope) {
2467+
$rootScope.$on('e', function() { $rootScope.$broadcast('e'); });
2468+
2469+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2470+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2471+
}));
2472+
2473+
2474+
it('should throw on nested recursive $broadcast', inject(function($rootScope) {
2475+
$rootScope.$on('e2', function() { $rootScope.$broadcast('e'); });
2476+
$rootScope.$on('e', function() { $rootScope.$broadcast('e2'); });
2477+
2478+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2479+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2480+
}));
2481+
2482+
2483+
it('should throw on recursive $emit', inject(function($rootScope) {
2484+
$rootScope.$on('e', function() { $rootScope.$emit('e'); });
2485+
2486+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2487+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2488+
}));
2489+
2490+
2491+
it('should throw on nested recursive $emit', inject(function($rootScope) {
2492+
$rootScope.$on('e2', function() { $rootScope.$emit('e'); });
2493+
$rootScope.$on('e', function() { $rootScope.$emit('e2'); });
2494+
2495+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2496+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2497+
}));
2498+
2499+
2500+
it('should throw on recursive $broadcast on child listener', inject(function($rootScope) {
2501+
var child = $rootScope.$new();
2502+
child.$on('e', function() { $rootScope.$broadcast('e'); });
2503+
2504+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2505+
expect(function() { child.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2506+
}));
2507+
2508+
2509+
it('should throw on nested recursive $broadcast on child listener', inject(function($rootScope) {
2510+
var child = $rootScope.$new();
2511+
child.$on('e2', function() { $rootScope.$broadcast('e'); });
2512+
child.$on('e', function() { $rootScope.$broadcast('e2'); });
2513+
2514+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2515+
expect(function() { child.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (2)');
2516+
}));
2517+
2518+
2519+
it('should throw on recursive $emit parent listener', inject(function($rootScope) {
2520+
var child = $rootScope.$new();
2521+
$rootScope.$on('e', function() { child.$emit('e'); });
2522+
2523+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2524+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2525+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2526+
}));
2527+
2528+
2529+
it('should throw on nested recursive $emit parent listener', inject(function($rootScope) {
2530+
var child = $rootScope.$new();
2531+
$rootScope.$on('e2', function() { child.$emit('e'); });
2532+
$rootScope.$on('e', function() { child.$emit('e2'); });
2533+
2534+
expect(function() { child.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2535+
expect(function() { $rootScope.$emit('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2536+
expect(function() { $rootScope.$broadcast('e', 5); }).toThrowMinErr('$rootScope', 'inevt', 'e already $emit/$broadcast-ing on scope (1)');
2537+
}));
2538+
2539+
2540+
it('should clear recursive state of $broadcast if $exceptionHandler rethrows', function() {
2541+
module(function($exceptionHandlerProvider) {
2542+
$exceptionHandlerProvider.mode('rethrow');
2543+
});
2544+
inject(function($rootScope) {
2545+
var throwingListener = jasmine.createSpy('thrower').and.callFake(function() {
2546+
throw new Error('Listener Error!');
2547+
});
2548+
var secondListener = jasmine.createSpy('second');
2549+
2550+
$rootScope.$on('e', throwingListener);
2551+
$rootScope.$on('e', secondListener);
2552+
2553+
expect(function() { $rootScope.$broadcast('e'); }).toThrowError('Listener Error!');
2554+
expect(throwingListener).toHaveBeenCalled();
2555+
expect(secondListener).not.toHaveBeenCalled();
2556+
2557+
throwingListener.calls.reset();
2558+
secondListener.calls.reset();
2559+
2560+
expect(function() { $rootScope.$broadcast('e'); }).toThrowError('Listener Error!');
2561+
expect(throwingListener).toHaveBeenCalled();
2562+
expect(secondListener).not.toHaveBeenCalled();
2563+
});
2564+
});
24512565
});
24522566
});
24532567

@@ -2537,7 +2651,7 @@ describe('Scope', function() {
25372651
expect(spy1).toHaveBeenCalledOnce();
25382652
expect(spy2).toHaveBeenCalledOnce();
25392653
expect(spy3).toHaveBeenCalledOnce();
2540-
expect(child.$$listeners['evt'].length).toBe(3); // cleanup will happen on next $emit
2654+
expect(child.$$listeners['evt'].length).toBe(2);
25412655

25422656
spy1.calls.reset();
25432657
spy2.calls.reset();
@@ -2571,7 +2685,7 @@ describe('Scope', function() {
25712685
expect(spy1).toHaveBeenCalledOnce();
25722686
expect(spy2).toHaveBeenCalledOnce();
25732687
expect(spy3).toHaveBeenCalledOnce();
2574-
expect(child.$$listeners['evt'].length).toBe(3); //cleanup will happen on next $broadcast
2688+
expect(child.$$listeners['evt'].length).toBe(2);
25752689

25762690
spy1.calls.reset();
25772691
spy2.calls.reset();

0 commit comments

Comments
 (0)