-
Notifications
You must be signed in to change notification settings - Fork 90
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
Exceptions and closures cause cleanup, in addition to cancelation? #109
Comments
Note that the cleanup logic applies to the current subscription. It does not need to terminate the underlying event stream. This is not dissimilar from arrays. I can request an iterator from an Array which terminates. This doesn't mean that the array is destroyed. In the meantime people can modify it, and the next time I retrieve an iterator for the array it may have different data. Note that in this case the underlying array is a hot data source, because the data that comes out of the iterator depends on when it is requested. Note that only the current subscription to the observable terminates in the event of an error. There is nothing stopping a consumer from immediately resubscribing to the observable should they want to continue listening for future events. There is no risk that they will miss events either, because it is possible to subscribe synchronously. |
Right, I understand how it's possible to work around this. But why is it a good idea? It's certainly an impedent to trying to create an observable system for DOM events, due to the broken programmer expectations. |
I'm not aware of any previous discussions on this topic (and I'm surprised we haven't talked about it here). In fact, the EventTarget implementation in terms of Observable I wrote messes this up. That doc can be repaired by just wrapping the I agree there is a clear mismatch between the DOM's handling of errors and errors in observables. I think the current behavior for Observable falls out of a couple of facts:
Imagine the following chain:
Where Currently, we close each subscription ( For the sake of argument, let's say that we don't close the subscriptions as the error flows back through them. When the error gets to @jhusain or @Blesh: Do you know if other Observable-ish libraries have a different take on this issue? |
xstream behaves differently. All streams in that library are hot, suitable for event sources like DOM clicks, and subscriber errors are not handled in any way. This code does not perform cleanup when the subscriber/listener throws: const stream = fromEvent(document, 'click');
stream.addListener({
next(ev) {
if (Math.random() < 0.5) {
throw new Error();
}
console.log('click');
},
error() {},
complete() {}
}); http://www.webpackbin.com/4yhdEb3cW On the other hand, error thrown in one listener will affect sibling listeners subscribed to the broadcast from the source stream const stream = fromEvent(document, 'click');
stream.addListener({
next(ev) {
if (Math.random() < 0.5) {
throw new Error();
}
console.log('click');
},
error() {},
complete() {}
});
stream.addListener({
next(ev) {
console.log('second click');
// will not run if an error was thrown in the first listener
},
error() {},
complete() {}
}); Also, @rpominov has built a Observable-ish library and has some ideas about expected/unexpected error handling in Promises/Observables, like #104 and https://github.com/rpominov/fun-task/blob/master/docs/exceptions.md. |
Yeah, Kefir does not catch errors at all, and in this particular case behaves similarly to xstream. |
I'm having trouble coming up with a killer argument for the current auto-closing behavior. Supposing that we didn't force cleanup on exception, the user could opt-in to the current auto-closing behavior with a pretty simple combinator. function closeOnError() {
return new Observable(observer => this.subscribe({
start(subscription) {
this.subscription = subscription;
},
next(x) {
try {
observer.next(x);
} catch (err) {
try {
this.subscription.unsubscribe();
} finally {
throw err;
}
}
},
error(x) { return observer.error(x) },
complete(x) { return observer.complete(x) },
}));
} (Untested) Usage: // Closes the stream on the first thrown error
document.on('click')::closeOnError().subscribe(e => {
if (Math.random() > 0.5) {
throw new Error();
}
console.log("clicked", e);
}); @jhusain what do you think? |
l too am having difficulty making the argument for auto closing. Currently looking around for some strong use cases. Leaning towards changing this behavior to better match with event target semantics. |
+1 that this type should have no opinion on errors in either Observers or Observables. Unexpected errors should be noisy and harmful (to the program) and therefore only protected against with explicit use of |
So when a producer signals completion, meaning the observer can never, ever send another value, we're going to leave resources hanging out until the consumer explicitly tears them down? Doesn't that mean consumers would always need to handle completions and errors manually tear down resources? Otherwise they risk leaks? I don't really see what the point of removing auto teardown is |
Seems like we're discussing two topics here that should be separate:
I am not certain about answer to the first question. Automatic tear down seems like a reasonable option, but there also libraries like Bacon and Kefir that allow multiple errors as well as multiple values. They basically try to treat values and errors symmetrically. As for exceptions I've expressed my opinion in #104 , it boils down to whether we want to support Either/Railways pattern or not: #104 (comment) . In other words what is the semantics of errors: do they model built-in Either or async try-catch? |
I feel like we're talking about 1. And I'd much rather there is a specific method that closes on error (like what @zenparsing demonstrated) only because making assumptions about how devs want to deal with errors is something this type should not be doing even if it means there might be memory leaks. Although, probably, bad practice some devs might want the subscription to stay open even after a error is thrown. |
Does it mean Observable would switch from having two types of subscription ending (one expected, one unexpected) to having two types of emitted values (expected values and errors)? If Observable are promise too, what would catch be used for? |
YES. Definitely. Otherwise the ergonomics of the type will be a hot mess, and there will be a huge memory leak footgun laying around. const source = new Observable(observer => {
let i = 0;
const id = setInterval(() => observer.next(i++), 1000);
return () => clearTimeout(id);
});
source.map(n => {
throw new Error('I hope you like intervals running forever, noob Observable user!!! LAWL');
});
const subscription = source.subscribe({
next(x) { console.log('Optimism!!!', x); }
}); Above it'll error on the first tick of the interval... but if you don't tear down, the interval keeps going. |
@Blesh In your example, wouldn't the subscriber just continue to receive errors? It wouldn't be a memory leak if the stream was still active. If the If I understand correctly, the idea is expected errors and values can be propagated down those two channels without the Observable ending, but unexpected errors would crash everything. I don't think he means "tear down" so much as "end". |
Perhaps it's difficult to separate these in this conversation. @Blesh if the answer to both of the above is "No" then I don't consider your example a memory leak footgun as the leak isn't hidden. The program will keep throwing uncaught errors which is pretty hard not to notice if you're debugging. However, if the answer to 2 is Yes, then it is possible the memory leak could be hidden as the user could potentially have a no-op for their error consumer. |
No. The observer is closed after the first error passes through it. It cannot send more than one error.
I didn't understand the second question, honestly. But the answer to 1 is an emphatic "yes". |
Let me try to explain. The second question is about implementation of map(fn) {
// ...
try {
newX = fn(x)
} catch (e) {
// put error into observable
}
// ...
} This behavior corresponds to answer "yes" to the second question. If we answer "no", we would change code above to: map(fn) {
// ...
newX = fn(x)
// ...
} In this case the only way to put a error into an observable would be by calling But this is really a separate subject, I think, which we should discuss in #104
I agree. If we allow only one error per observable, in case of If we allow more than one error per observable though, than teardown should not be called in this case |
Ok, so I've stared at this for a while now and I also find it really surprising that this wasn't brought up before. This is a really good point. I dug our Rx code for the last hour and it all behaves in the same semantics @Blesh described. It never really gave us issues as we monitor and treat any thrown exception in an event handler as an error anyway. I wouldn't like to give up cleanup logic on exceptions. I think the way we approach this problem doesn't really depend on Rx per-se, async iterators have the exact same issue and so do regular iterators. Cleanup on errors is an extremely useful guarantee and I've used it (admittedly less so with DOM events) very liberally before (kind of like I think this is something the |
Why is this something the |
It seems Observable is used in two contexts:
The async function test() {
try {
await Observable.from([1,2,3]).forEach(x => {
throw "error";
});
}
catch(e) {
console.log(e);
}
} This matches the behavior of Array's synchronous forEach, and I believe any other behavior would be very unexpected - not to mention a refactoring hazard. However if a developer is using subscribe and callbacks they may well expect EventTarget-like semantics. Under the circumstances I think let {cancel, token} = CancelToken.source();
observable.subscribe({
next(v) {
try {
doSomethingThatMightFail(v);
}
catch (e) {
cancel(new Cancel("Something went wrong"));
}
}, token); If |
It might be helpful to look at how we would implement auto-close on next-throw if it wasn't provided by SubscriptionObserver: new Observable(sink => {
// Use this instead of sink.next
function next(value) {
try {
sink.next(value);
} catch (error) {
try { sink.complete(); } // Or sink.error?
finally { throw error; }
}
}
return () => {
// cleanup
};
}) The wrapping of the SubscriptionObserver can be abstracted quite nicely: new Observable(sink => {
sink = AutoClosing(sink);
return () => {
// cleanup
};
}) How would we implement no-close-on-throw, given the current spec? It seems to me like it would be quite difficult because the subscription is completely shut down on throw. I think you'd have to wrap the entire observable with a different It looks to me like it's fairly easy to go the one way, and pretty hard to go the other. With that in mind, I'd argue for changing the behavior per #123 unless it can be shown that not performing cleanup on next-throw is a footgun. |
Agreed. |
I'd like to propose the following mental model: calling If a consumer throws while consuming a value, note that the iterator does not self close. let iterator = [1,2,3][Symbol.iterator]();
let { value } = iterator.next();
value.someFunctionThatDoesntExist(); // throws and leaves iterator open This corresponds to the proposed subscribe behavior, where throwing does not close the subscription: observable.subscribe({
next(value) {
value.someFunctionThatDoesntExist(); // throws and leaves subscription open
}
}); Let's contrast this with for...of... for(value of [1,2,3]) {
value.someFunctionThatDoesntExist(); // throws and closes iterator
} ...and forEach, each of which close the subscription if code handling the value throws: observable.forEach(value => {
value.someFunctionThatDoesntExist(); // throws and closes subscription
}); Seems like we have a straightforward correspondence here. |
@jhusain So we've spent a lot of time discussing what happens in an error in an operation like const source$ = new Observable((observer, token) => {
token.promise.then(() => {
console.log('will this be called?');
});
observer.throw(new Error('haha'));
});
source$.subscribe(); // here?
source$.forEach(() => { }); // what about here? Or in the older model... const source$ = new Observable((observer, token) => {
setTimeout(() => observer.error(new Error('haha')));
return () => {
console.log('will this be called?');
};
});
source$.subscribe(); // here?
source$.forEach(() => { }); // what about here? |
Yes. If an Observable implementation sends a error or complete notification then cleanup behavior is executed. |
👍 Solid. Thanks |
@jhusain - I didn't know that iterators had this behavior but if it's well known enough not to be a foot-gun then 👍 |
Apologies if this has been discussed before; I'll gladly take pointers to existing issues.
When talking with @jhusain about the cancelation design, I was told that not only does explicit cancelation cause the cleanup function to run, but so does throwing an error or reaching the end of the observable. So, given the following:
If I understand correctly, we have a 50% chance each click of stopping the click-stream forever (since the cleanup operation here is to do removeEventListener).
This seems like a big mismatch with the event semantics of the DOM, where errors in event listeners get reported, but do not cause all future events to stop firing.
It's less clear to me whether the cleanup function should be called after the event stream ends, because in the DOM event streams don't generally end, but even in cases where they kind of do (e.g. progress events), there's no need to unsubscribe---garbage collection removes the listeners for us anyway.
The text was updated successfully, but these errors were encountered: