-
Notifications
You must be signed in to change notification settings - Fork 3k
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
Fix/5487 reentrancy in take, takeWhile, and first #6396
Fix/5487 reentrancy in take, takeWhile, and first #6396
Conversation
Note: also fixes |
src/internal/operators/take.ts
Outdated
// that here. | ||
if (seen < count) { | ||
subscriber.error(err); | ||
} |
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.
out of interest: is there any particular reason, why blocking notifications over unsubscribing before the last next notification was used?
(besides the failing delayWhen test)
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.
Sorry for the late response. I'm not sure I understand your question here.
I don't believe that the changes made to I mentioned that the primary reason I consider the behaviour described in the issue to be a bug is because it does not behave as I'd expect with side effects. Specifically, with upstream side effects. I would expect const subject = new BehaviorSubject<number>(1);
subject
.pipe(
tap((value) => console.log(`side effect: ${value}`)),
take(1),
tap((value) => subject.next(value + 1))
)
.subscribe((value) => console.log(`output: ${value}`)); This will output:
So it doesn't take one value from the upstream source; it takes two. And the snippet could be tweaked to make it take many, many more. This problem - and the problem in the original issue - can be solved by having the operator explicitly unsubscribe itself: operate((source, subscriber) => {
let seen = 0;
const operatorSubscriber = new OperatorSubscriber(
subscriber,
(value) => {
if (++seen >= count) {
operatorSubscriber.unsubscribe();
subscriber.next(value);
subscriber.complete();
} else {
subscriber.next(value);
}
}
);
source.subscribe(operatorSubscriber);
}); I don't see why this wouldn't be a suitable solution:
The change suggested above would effect different behaviour, but the change in this PR will alter behaviour anyway. |
Hmm, having written this ☝ I've thought of a reason why it's not a good idea. For observable sources that emit values that are dependent upon the lifecycle of the source, this won't work - as the source would be torn down before the last taken value is nexted. We should probably add a test for this scenario, if it's something that we want to make sure we don't break. |
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.
Also i am not sure that fix of this problem right now will not affect already written code which can depend on this behaviour. Seems that is a Breaking Change.
I mean do 'unsubscribe source before emit last value next'. So maybe make sense to fix it in major release.
I agree with (almost) everything @cartant wrote.
But this kind of observable would not work with a lot of operators. besides that, one could argue that it is bad practice to tie the lifecycle of values to the lifecycle of the observable.
i dont think this guarantee can be made or should be made. notification behavior can only be guaranteed downstream. how upstream is handled is not part of the observable contract, imho. also according to http://reactivex.io/documentation/contract.html (if this is still a reference) it is perfectly fine for an observable to notify after unsubscribe. edit: I still favor @cartant's version of the fix |
Still thinking about this solution. With @cartant's solution, I'm not sure how const subject = new Subject<number>();
merge(of(1), subject).pipe(
finalize(() => console.log('finalize 1')),
take(1),
finalize(() => console.log('finalize 2')),
tap(() => subject.next(2))
)
.subscribe({
next: console.log,
complete: () => console.log('done')
}) I think I'd expect:
But with @cartant's fix I think we'd get:
|
Yeah... So what we'd want would be more something like: operate((source, subscriber) => {
let seen = 0;
const operatorSubscriber = new OperatorSubscriber(
subscriber,
(value) => {
if (++seen >= count) {
operatorSubscriber.stop(); // New thing that flags `isStopped` up the chain, but doesn't teardown
subscriber.next(value);
subscriber.complete();
} else {
subscriber.next(value);
}
}
);
source.subscribe(operatorSubscriber);
}); ... and I think the added complexity of the "proper fix" here is too high for the payoff, given I don't think this issue is common and honestly it reads like a code smell more than anything. Longer term, this quirk is sort of an argument in favor of scheduling all the things. It's not something I like at all, TBH. But it would solve this problem extremely cleanly and make it all predictable. (I really don't like it, but it's an argument in favor of that, IMO) |
Separate to discussions about scheduling or not - synchronous reentrant in rxjs is always undefined behavior and we discourage to do it. Is there specifics around this bug need to be treated differently? or will we trying to fix other synchronous reentrant? |
FWIW: I think @cartant and I are in violent agreement:
|
i dont think that
is an unexpected result. from the upstream's points of view, that is exactly what should happen, no? from downstream we dont care. as I understand do we consider these 2 statements as correct?
|
I'm still thinking about this. |
Yeah... hmm.. in RxJS 6 and 7 (not sure about others), this is all kinds of weird/wrong: let n = 0;
const subject = new Subject<number>();
merge(subject, of(n++))
.pipe(
finalize(() => {
console.log('finalized');
}),
take(10),
tap(() => subject.next(n++))
)
.subscribe({
next: console.log,
complete: () => console.log('done')
});
// outputs
// 9
// done
// finalized |
Okay... after some thought, and discussion with @cartant, I think the correct thing to do is to have it The principle behind this is simply that:
That said... this is a BREAKING CHANGE and we cannot have it until version 8. |
// Because this is reentrant, the last "taken" value of 5 is followed by `complete()` | ||
// which shuts off all value emissions. Otherwise, this would be | ||
// [5, 'done', 4, 3, 2, 1, 'finalize source', 'finalize result'] | ||
expect(results).to.deep.equal([5, 'done', 'finalize source', 'finalize result']); |
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.
this one is weird. is this considered expected/ok behavior? guess the solution would be to subject.pipe(observeOn(asapScheduler))
}); | ||
}); | ||
|
||
it('should not emit errors sent from the source *after* it found the first value in reentrant scenarios', () => { |
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.
does not fail on master, but idk how to even test this one
23fb964
to
27a23c3
Compare
27a23c3
to
d6e2c08
Compare
d6e2c08
to
f684e9a
Compare
- Resolves an issue where a reentrant error notification would short circuit the completion. - Adds additional tests. Related ReactiveX#5487 BREAKING CHANGE: If a the source synchronously errors after it recieves a completion notification, the error will no longer be emitted. This is a bug fix, but may be a breaking change for those relying on this behavior. If you need to mimic the behavior, you'll need to throw the error before the takeWhile notifier is notified.
- Resolves an issue with both `first` and `take` where an error emitted by a reentrant source at the moment the last value it taken would result in superceding the expected emitted value and completion. - Resolves a similar issue where a reentrant completion would supercede the expected last value and completion Fixes ReactiveX#5487 BREAKING CHANGE: If a the source synchronously errors after `take` or `first` notice a completion, the error will no longer be emitted. This is a bug fix, but may be a breaking change for those relying on this behavior. If you need to mimic the behavior, you'll need to throw the error before `take` or `first` notice the completion.
- Removes a function that had no real purpose, as the only check in it was impossible to hit. - Adds some comments.
- adds test, fixed by changes to `take`. Related ReactiveX#5487 BREAKING CHANGE: If a the source synchronously errors after it recieves a completion from `elementAt`, the error will no longer be emitted. This is a bug fix, but may be a breaking change for those relying on this behavior. If you need to mimic the behavior, you'll need to throw the error before `elementAt` finds the element at the index.
f684e9a
to
bf72a43
Compare
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.
LGTM
fix(first/take): behave properly with reentrant errors and completions
first
andtake
where an error emitted by a reentrant source at the moment the last value it taken would result in superceding the expected emitted value and completion.Fixes first/tap operators should unsubscribe before emit next value #5487
fix(takeWhile): reentrant errors and completions behave properly
Related first/tap operators should unsubscribe before emit next value #5487
chore: Add reentrant error test for takeUntil