Skip to content

fix(core): fix proper propagation of subscriptions in EventEmitter #22016

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

Conversation

martinsik
Copy link
Contributor

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

[x] Bugfix
[ ] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Documentation content changes
[ ] angular.io application / infrastructure changes
[ ] Other... Please describe:

What is the current behavior?

Issue Number: #21999

Live demo: https://stackblitz.com/edit/angular-9kvrfk?file=src/app/app.component.ts

Right now the EventEmitter class doesn't properly remove observers when not subscribing directly to EventEmitter and using an intermediate Subscription object. In other words when chaining EventEmitter with any operator that internally creates a Subscription object the EventEmitter isn't able to unsubscribe it properly by calling its teardown function.

This can be seen in the two following examples:

const ee = new EventEmitter();
ee.pipe(map(() => true)).subscribe().unsubscribe(); // can any other operator
console.log('EventEmitter', ee.observers.length); // 1 - wrong, should be 0

// This simulates `FilterSubscriber` or `MapSubscriber`
const subscriber = new Subscriber(val => {});
subscriber.add(() => console.log('Subscriber disposed')); // This is never called - wrong

const ee2 = new EventEmitter();
ee2.subscribe(subscriber).unsubscribe();
console.log('EventEmitter - Subscriber', ee2.observers.length); // 0
// This did unsubscribe because we're holding the direct `Subscription` for `EventEmitter`
// but didn't trigger the teardown function. The teardown function is
// where it unsubscribes from its parent in `FilterSubscriber` or `MapSubscriber`.

For example if I take the first example with just ee.pipe(map(() => true)).subscribe().unsubscribe(); it should work just like it does with the original Subject from RxJS 5.5:

const s = new Subject();
s.pipe(map(() => true)).subscribe().unsubscribe();
console.log(s.observers.length); // 0 - correct

I described it in more detail in #21999 (comment) but basically the problem is that the EventEmitter creates functions schedulerFn, errorFn and completeFn that are inside super.subscribe() wrapped with another Subscription object which is then returned.

However it never "connects" the original generatorOrNext (which is Subscription object itself) with the new Subscription returned from super.subscribe().

What is the new behavior?

Properly calls teardown functions from the original Subscription. The following tests would fail without this PR:

it('remove a subscriber subscribed after applying operators with pipe()', () => {
  const sub = emitter.pipe(filter(() => true)).subscribe();
  expect(emitter.observers.length).toBe(1);
  sub.unsubscribe();
  expect(emitter.observers.length).toBe(0);
});

it('error thrown inside an Rx chain propagates to the error handler and disposes the chain',
   () => {
     let errorPropagated = false;
     emitter.pipe(filter(() => { throw new Error(); }), )
         .subscribe(() => {}, err => errorPropagated = true, );

     emitter.next(1);

     expect(errorPropagated).toBe(true);
     expect(emitter.observers.length).toBe(0);
   });

Does this PR introduce a breaking change?

[ ] Yes
[x] No

Other information

Closes #21999

Copy link
Contributor

@IgorMinar IgorMinar left a comment

Choose a reason for hiding this comment

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

This looks good to me. @mhevery can you take a look as well?

@IgorMinar IgorMinar added the action: review The PR is still awaiting reviews from at least one requested reviewer label Feb 5, 2018
@IgorMinar IgorMinar added this to the v6-candidates milestone Feb 5, 2018
@IgorMinar IgorMinar self-assigned this Feb 5, 2018
});

it('unsubscribing a subscriber invokes the dispose method', () => {
inject([AsyncTestCompleter], (async: AsyncTestCompleter) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this test need to be async?

Also seems like neither this test nor the next fail before the fix. Should one of them be failing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This one is async because it needs to be sure the dispose method will be called. I know this one would probably work without async because Subjects in RxJS work synchronously but this would rely only on the Subject internals that might change.

The other tests are to make sure I didn't break anything related to this issue while fixing this.

Copy link
Contributor

Choose a reason for hiding this comment

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

But you mentioned in your comment that there is a case where the teardown is not called? I don't see any failing test to demonstrate that case and I couldn't reproduce it myself...

Copy link
Contributor Author

@martinsik martinsik Feb 5, 2018

Choose a reason for hiding this comment

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

@jbedard That's the same use-case as the original example :).

emitter.pipe(filter(() => true)).subscribe().unsubscribe();

Copy link
Contributor

Choose a reason for hiding this comment

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

This one?

// This simulates `FilterSubscriber` or `MapSubscriber`
const subscriber = new Subscriber(val => {});
subscriber.add(() => console.log('Subscriber disposed')); // This is never called - wrong
const ee2 = new EventEmitter();
ee2.subscribe(subscriber).unsubscribe();
console.log('EventEmitter - Subscriber', ee2.observers.length); // 0
// This did unsubscribe because we're holding the direct `Subscription` for `EventEmitter`
// but didn't trigger the teardown function. The teardown function is
// where it unsubscribes from its parent in `FilterSubscriber` or `MapSubscriber`.

Is that one in this PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jbedard Yes, that's the same problem as with this:

emitter.pipe(filter(() => true)).subscribe().unsubscribe();

@mhevery mhevery added target: patch This PR is targeted for the next patch release and removed action: review The PR is still awaiting reviews from at least one requested reviewer labels Feb 5, 2018
@mhevery
Copy link
Contributor

mhevery commented Feb 5, 2018

@mhevery mhevery added the action: merge The PR is ready for merge by the caretaker label Feb 6, 2018
@alxhub alxhub closed this in e81606c Feb 6, 2018
jbogarthyde pushed a commit to jbogarthyde/angular that referenced this pull request Feb 23, 2018
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Sep 13, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
action: merge The PR is ready for merge by the caretaker area: core Issues related to the framework runtime cla: yes target: patch This PR is targeted for the next patch release type: bug/fix
Projects
None yet
Development

Successfully merging this pull request may close these issues.

.pipe operators on EventEmitter leaks subscriptions
5 participants