Skip to content
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

SubscriptionObserver should never throw, just like Promise #119

Closed
jhusain opened this issue Nov 28, 2016 · 158 comments
Closed

SubscriptionObserver should never throw, just like Promise #119

jhusain opened this issue Nov 28, 2016 · 158 comments
Labels

Comments

@jhusain
Copy link
Collaborator

jhusain commented Nov 28, 2016

Observables, like Promises, can be either multicast or unicast. This is an implementation detail, should not be observable. In other words, it should be possible to switch an Observable from unicast to multicast without changing any code in any of the Observers of that Observable.

Given that design constraint it seems necessary that SubscriptionObservers, like Promises, must swallow errors. Allowing errors thrown from Observers to propagate can leak details of whether an Observable is unicast or multicast. To demonstrate this issue, consider the following Subject implementation:

class Subject extends Observable {
  constructor() {
    super(observer => {
      this._observers.add(observer);
    });
    this._observers = new Set();
    return () => this._observers.delete(observer);
  }

  _multicast(msg, value) {
    const observers = Array.from(this._observers);
    for(let observer of observers) {
      observer[msg](value);
    }
    return undefined;
  }

  next(value) {
    return this._multicast("next", value);
  }
  error(error) {
    return this._multicast("error", error);
  }
  complete(value) {
    return this._multicast("complete", value);
  }
}

Note in the code above that if an observer throws from either next, error, or complete, then none of the remaining observers receive their notifications. Of course, we can guard against this by surrounding each notification in a catch block, capturing any error, and rethrowing it later.

  _multicast(msg, value) {
    const observers = Array.from(this._observers);
    let error;
    for(let observer of observers) {
      try {
        observer[msg](value);
      }
      catch(e) {
        error = e;
      }
    }

    if (error) {
      throw error;
    }
    return undefined;
  }

Unforunately this only works if there is only one error. What if multiple observers error? We could aggregate all observer errors into a CompositeError like so:

  _multicast(msg, value) {
    const observers = Array.from(this._observers);
    let errors = [];
    for(let observer of observers) {
      try {
        observer[msg](value);
      }
      catch(e) {
        errors.push(e);
      }
    }

    if (errors.length) {
      throw new CompositeError(errors);
    }
    return undefined;
  }

Unfortunately now we have a new leak in the abstraction: a new error type which is only thrown from multi-cast Observables. This means that code that captures a particular type of error may fail when an Observable is switched from unicast to multicast:

try {
  observable.subscribe({ next(v) { throw TypeError })
} catch(e) {
  // worked before when observable was unicast,
  // but when switched to multicast a CompositeError thrown instead
  if (e instanceof MyCustomError) {
    //...
  }
}

Once again we can see that the interface changes when moving from a uni-cast to multi-cast Observable, and the implementation details leak out.

The only way I can see to cleanly abstract over multi-cast and uni-cast Observables is to never mix push and pull notification. I propose the following simple rule: subscribe never throws. All errors are caught, and if there is no method to receive the push notification that the error occurred, the error is swallowed.

@jhusain jhusain added the bug label Nov 28, 2016
@zenparsing
Copy link
Member

Interesting point.

So, more specifically, are you suggesting that before subscribe returns, the SubscriptionObserver will not allow the error to propagate? Or that SubscriptionObserver will never allow errors to propagate?

@zenparsing
Copy link
Member

Another option besides swallowing would be to use HostReportErrors to allow the browser to report the exception (just like exceptions are reported for DOM event handlers). And Node would terminate, of course.

This might be more appropriate, since (unlike with promises) a swallowed error would be simply thrown away without any ability to recover it at a later time.

@mpj
Copy link

mpj commented Nov 28, 2016

Please, please, find ways to avoid error swallowing. Swallowed errors is an absolutely dreadful experience.

This is my main gripe with many of the current observable implementations out there. If you have a chain of observables that you're debugging and it's not obvious where the error is occuring, it is going to take so much time to debug it that any productivity benefit you got from using observables is void and null.

@benjamingr
Copy link

@mpj note that promises solve this by emitting an event whose handler defaults to logging the error - so it's possible to make this not swallow errors.


@jhusain I think our only real option is to do what @zenparsing suggests so observables behave the same way as DOM event listeners or Node event listeners.

Node has a special behavior in event listeners where .on("error" is given a chance to clean up first before the exception is thrown "globally", maybe it makes sense to fire error on observables to allow this. The DOM just assumes the user code handles this.

I also don't understand your code in:

try {
  observable.subscribe({ next(v) { throw TypeError })
} catch(e) {
  // worked before when observable was unicast,
  // but when switched to multicast a CompositeError thrown instead
  if (e instanceof MyCustomError) {
    //...
  }
}

Either the observable fires immediately when you subscribe to it at which point it's fine since every handler subscribing would have a chance to clean up when it is subscribed to (so the code above would work with multicast).

Or the observable is emitting asynchronously so you wouldn't get in the catch block anyway. You can't just try/catch around observables and assume it'd work since it violates a much stronger assumption than uni/multicast IMO - that observables might emit asynchronously and your code should be agnostic to that.

@acutmore
Copy link

+1 for HostReportErrors

I feel it is common practice/courtesy across many different programming languages/communities to not intentionally throw from inside a listener/callback as part of the designed program control flow.

So making sure the error is logged to a global, non Observable specific, error sink which is highly likely to be monitored so errors are picked up and fixed seems the correct choice to me. As opposed to encouraging/facilitating errors to be caught and handled locally to the subscribe

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 28, 2016

@benjamingr the issue I'm trying to demonstrate with the code example you reference is that if we aggregate all observer errors and throw (the only way to avoid any form of swallowing), the error type changes when moving from unicast to multicast. Therefore the leak persists.

@mpj Using the same approach as Promises is really the only way forward here. The debate over error swallowing was effectively finished when Promises were standardized. Given the example in this thread, I'm now convinced that the committee made the right decision.

I'm strongly in favor of using whatever mechanism Promises use to signal uncaught errors. If that is HostReportErrors, that's what we should do.

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 28, 2016

@zenparsing I'm suggesting that SubscriptionObserver will never allow errors to propagate. Presumably the responsibility to prevent propagation could lie with the subscribe impl, but not sure why we'd want that.

@kirkshoop
Copy link

Rxcpp defaults on_error() to call terminate(), if no function is supplied to subscribe(). This sounds like the same principle as HostReportErrors. So obviously, I think that is the correct default. :)

@zenparsing
Copy link
Member

zenparsing commented Nov 29, 2016

@jhusain Promises use HostPromiseRejectionTracker which is geared specifically to the challenges of reporting promise rejections. As the note says, it's called both when a promise is rejected without any handlers and also when a handler is added to an already-rejected promise.

Since a handler can't be added "later" to an error coming from an observable/observer, I don't think it would work too well to use HostPromiseRejectionTracker. HostReportErrors is probably more appropriate.

Also, it makes sense that we would model errors which happen in observables using the same kind of mechanisms that have worked with EventTarget.

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 29, 2016

@zenparsing Would you report to HostReportErrors if an error was sent to an Observer twice, like so:

let observable = new Observable(observer => {
  observer.error("FAIL");
  observer.error("FAIL");
  return () => {};
});

observable.subscribe({ next(v) { }, error(e) { }, complete(v) { } });

This would be pretty arduous, because developers would always have to check the closed() value prior to notifying. Having the observer noop all calls after the subscription has closed is much more ergonomic.

I'm inclined to send errors to HostReportErrors in the following situations:

  1. a next, error, or complete handler throws
  2. no error handler exists to receive an error

Any thoughts?

@trxcllnt
Copy link

I thought we'd long ago decided that errors thrown by next/error/complete is undefined/default behavior and should propagate to the global scope (same as errors thrown in node-flavored callbacks). This is the behavior Alex Liu and the Netflix node teams desire so they can get an accurate core-dump, and something we can support with minimal effort in Rx.

If we are going to catch errors thrown from next/complete, forwarding them on to the error handler (like I've argued for in the past) would also solve this problem. If the first observer throws, the error is forwarded to its error handler, and all observers awaiting notification. If an observer's error handled throws, that error is used instead. If the last Observer's 'error' throws, the error is re-thrown globally.

@benjamingr
Copy link

@benjamingr the issue I'm trying to demonstrate with the code example you reference is that if we aggregate all observer errors and throw (the only way to avoid any form of swallowing), the error type changes when moving from unicast to multicast. Therefore the leak persists.

Right but that's not really an issue since the catch (e) { strategy doesn't work in either unicast or multicast anyway. Since it's asynchronous you have to report the error globally, don't even run the other observers let alone execute their errors. I'm also in favor of HostReportErrors which aligns with the DOM event model.

@trxcllnt
Copy link

see also: #47

@zenparsing
Copy link
Member

@jhusain

Having the observer noop all calls after the subscription has closed is much more ergonomic.

I agree.

I'm inclined to send errors to HostReportErrors in the following situations: ...

Agree with this as well.

A related question (which I think you alluded to in the original post): currently the spec says that the return value from the observer's callback is transmitted back to the caller through SubscriptionObserver. Does the change proposed here mean that we need to revisit that choice? Should the return value from the observer callbacks be discarded?

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 29, 2016

Taking the principle of "push-only" further, it makes no sense whatsoever that you cannot receive an error thrown from next/error/complete, but you can receive a return value. Being fully push means that no data is pulled whatsoever. Under the circumstances I see no justification for receiving anything from next/complete/error but undefined. In addition to catching errors, SubscriptionObserver must suppress all return values from downstream Observers and return undefined.

The change that the various Observer notifications return void actually brings the proposal closer to most existing implementations. Furthermore it's never been fully explained what happens to the return value when notifications are scheduled (ex. using observeOn operator). This lossiness smells, because it leaks whether downstream notifications are handled sync or async.

@zenparsing
Copy link
Member

Taking the principle of "push-only" further, it makes no sense whatsoever that you cannot receive an error thrown from next/error/complete, but you can receive a return value.

I think that makes sense to me. My original intention was to easily allow async observers (where the callbacks can return a Promise), but I agree it doesn't "come together" nicely.

Another question comes to mind:

Currently we allow errors thrown from the cleanup function to propagate to the caller of unsubscribe():

let observable = new Observable(observer => {
  return () => { throw new Error('error in cleanup') };
});
let subscription = observable.subscribe({});
subscription.unsubscribe(); // Throws a catchable error here

In light of the proposed changes here, does the current behavior still make sense?

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 29, 2016

BTW @trxcllnt I'm increasingly warming to your idea that throwing from next should forward to the error handler. Will comment more in the corresponding issue.

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 30, 2016

@zenparsing with respect to what subscriptions do, it's not clear to me that allowing subscriptions to throw interferes with our ability to cleanly abstract over unicast and multicast Observables. Can you think of an example where this would be problematic?

@zenparsing
Copy link
Member

@jhusain I can't think of any. Allowing the subscriber to "see" errors coming from the producer doesn't appear to violate the one-way flow constraint.

I'm concerned about how this change will impact frameworks though. For instance, in an express app any errors that occur within a route handler need to be catchable by the framework so that it can invoke it's per-request error handler (which might show a 500 page or something). What do you think?

@jhusain
Copy link
Collaborator Author

jhusain commented Nov 30, 2016 via email

@zenparsing
Copy link
Member

After thinking this over for a while, I'm not sure I quite understand the rationale for disallowing return values and exceptions to pass to the producer. From the motivating example:

try {
  observable.subscribe({ next(v) { throw TypeError })
} catch(e) {
  // worked before when observable was unicast,
  // but when switched to multicast a CompositeError thrown instead
  if (e instanceof MyCustomError) {
    //...
  }
}

First, why should we be able to make the assumption that switching observable with some other observable (with different behavior) should result in the same throwing behavior for subscribe? Is the switch from unicast to multicast special for some reason? Why?

Second, it's pretty easy to create an Observable from the current spec which swallows errors:

new Observable(sink => {
  sink = new SwallowingObservable(sink);
});

So why can't producers opt-in to this behavior if they want?

I'm pretty confident that users will come up will cool use cases for returning values back to the producer if we allow it. The danger here is artificially limiting possibilities like:

observable.subscribe({
  async next() { },
});

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 6, 2016 via email

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 6, 2016 via email

@zenparsing
Copy link
Member

Are there are any userland examples you're aware of?

Yeah, I have a work project where we use zen-observable (which supports return values), and we use a multicast observable for publishing and subscribing to server-side events which might happen on an HTTP request. The subscriber can return a promise and the publisher can await all of the various results before proceeding on with the request processing.

We can change things so that instead of returning a promise we set a promise on the "event" object, but given async functions it's a lot less ergonomic to do it that way.

@staltz
Copy link

staltz commented Dec 7, 2016

👎 for returning values back to the producer. In that case we could just rename this to Flowable. Like in RxJava v2: https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0

There is so much that is battle proven in RxJS that there isn't a lot of reason to deviate from it. If we're looking into making this a 2-way communication primitive, I'd rather back some simpler and cleaner primitive like CSP channel.

@trxcllnt
Copy link

trxcllnt commented Dec 7, 2016

From what I remember, the initial back-pressure work in RxJava proved returning values from next doesn't compose, right @jhusain?

@RangerMauve
Copy link

@zenparsing

The subscriber can return a promise and the publisher can await all of the various results before proceeding on with the request processing.

In my opinion, for duplex communication like that, async iterators are a much nicer fit. Though they aren't as far along last I checked. I agree that removing return values and making things more push based would be useful for the spec. It would also help differentiate Observables from Iterators and Async Iterators. If the two are too similar, it could be confusing as to which tool is right for what job.

@benjamingr
Copy link

Though they aren't as far along last I checked.

They're actually very far along - being a stage 3 proposal while observables and cancellation tokens are both stage 1. They're also much simpler to model.

@zenparsing
Copy link
Member

@isiahmeadows I believe there are use cases (some Observable operators) where error and complete need to be delivered synchronously, but I can't remember exactly what they are.

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 28, 2016 via email

@ljharb
Copy link
Member

ljharb commented Dec 28, 2016

By default, node plans to crash on unhandled errors when a Promise is GCed, but can't ever practically do that by default prior because a later-handled rejection is a critical use case for Promises. Users will be able to opt into immediately crashing by adding their own unhandled rejection callback, of course. Browsers would surely be limited by the same compat restrictions.

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 28, 2016

@zenparsing Can you quickly answer a few questions? I've been reviewing the thread and I'm not clear on exactly what semantics you'd like to see for Observable at this point. Can you quickly summarize? I have the following questions:

  • do you believe consumer errors should be sent to "error" and the Subscription should be closed?
  • do you believe consumer errors should be allowed to propagate through subscribe in the event notifications are sent synchronously?
  • What state should the consumer expect the subscription to be in if subscribe throws? Closed? The consumer has no Subscription in the event of a thrown error, so if an Observable doesn't explicitly guard against errors thrown from notifications wouldn't there necessarily be a memory leak?

For my part I contend that consumer errors should be caught and reported to HostReportErrors, and should not close the subscription. Furthermore I think "subscribe" should report producer errors to HostReportErrors in the event an "error" callback is not provided.

Thanks

@benlesh
Copy link

benlesh commented Dec 28, 2016

Couldn't we fix the issue by making appropriate adjustments to Subject so that it's multiple observers don't interfere with each other?

If we "fix" this even just for subjects, it would break your use case of wrapping subject.next(x) in a try/catch.

@zenparsing
Copy link
Member

@jhusain I think the spec should stand as-is. That is:

  • Consumer errors are not sent to error.
  • Errors that occur in the subscriber (executor/whatever-you-want-to-call-it) function are sent to error if present, otherwise propagate normally.

if we decided to add a "subscribe"-like method to Promise tomorrow, would the Promise swallow errors thrown from handlers? Yes. Why? Because promises are multicast.

Sorry, but "subscribe-for-Promise" aight gonna happen.

You are trying to design a low-primitive to support a particular implementation of a pattern for which no precedent has been demonstrated outside of your own code.

Please, let's try to avoid ad hominem.

We're trying to design an interface which can be ergonomically consumed without leaking the details of its implementation

But you haven't proved how the proposed changes help this goal more than they hurt, other than providing abstract and opinionated notions about how Observable ought to be used. In all of the code examples I've looked at so far, the issue can be resolved by fixing the Subject/multicast implementation to do what exactly you advocate, without violence to the exiting low-level semantics. And that fix can be made one time, in one place, and multicast users never have to think about it again.

I invite you to help me understand why that's not sufficient.

@zenparsing
Copy link
Member

@Blesh

If we "fix" this even just for subjects, it would break your use case of wrapping subject.next(x) in a try/catch.

Someone (trying to keep the personal "I" out of it) that wants to catch errors from a subject.next can just use a different "subject-like" abstraction that allows errors to propagate (e.g. CompositeError in the OP). Observable is a building-block and users should be able to build lots of different things with it.

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 28, 2016 via email

@zenparsing
Copy link
Member

Apologies for the short reply, I need to leave the laptop for a while.

What do you mean "fix"? Should consumer errors be allowed to propagate through the stack or not?

As far as this spec goes, yes. It will make sense in some multicast scenarios to not allow errors to unwind the stack. It's not an either-or situation, though. The fact that (some) multicast use cases want to disallow propagation doesn't mean that we have to disallow it for all observables. Case-in-point: EventEmitter.

This means that consumers have to provide an explicit, empty error callback to ignore errors.

Yes, absolutely! This has always been my expectation. Just like in pull code you have to use try/catch to ignore errors:

try {
  for await (let value of asyncIterator) {}
} catch (e) {
  // Ignore
}

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 29, 2016 via email

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 29, 2016 via email

@dead-claudia
Copy link

@zenparsing

@isiahmeadows I believe there are use cases (some Observable operators) where error and complete need to be delivered synchronously, but I can't remember exactly what they are.

Opened #131 for clarification.

@zenparsing
Copy link
Member

The topic of EventEmitter has been covered thoroughly.

Sorry, I still don't agree that it's acceptable to restrict the generality of Observable such that it eliminates EventEmitter semantics. To me, EventEmitter is a completely reasonable push stream abstraction.

If we allow consumer errors to propagate and become producer errors, we still have no push/pull equivalence. No such thing can occur with iterators.

Because of the caller/callee inversion when we go from pull to push. Regardless of push/pull, the call stack always points the same direction, and call stack semantics (such as error propagation) don't change.

[push]
========> (callstack)
--------> (data)

[pull]
========> (callstack)
<-------- (data)

Based on your proposed semantics, it seems as though the only reliable way of intercepting consumer errors is to try/catch each Observer method, no?

Well, those are the current semantics, not a proposed semantics 😄

If I understand your question, then yes, in accordance with normal call stack semantics, if a consumer throws an error then the producer must use try/catch to intercept it.

new Observable(sink => {
  setTimeout(() => {
    try { sink.next(1); }
    catch (e) { console.log('You goofed but who cares?'); }
  }, 5000);
}).subscribe({
  next(v) { throw new Error('oops') }
});

My impression is that more often, for async notifications, the producer will just let the error propagate down the call stack back to the host and let it become an unhandled exception.

can you clarify your status on the committee for me?

Consider me a concerned and informed citizen that doesn't want to see the champions of this proposal make a mistake. (Honestly, I don't know why I put so much effort into this!)

Plus, I got it right the first time. 😉

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 29, 2016 via email

@jhusain jhusain closed this as completed Dec 29, 2016
@zenparsing
Copy link
Member

Thanks, I will not be contributing any further to this effort and leave behind (yet another) TC39 effort with a bad taste in my mouth.

@trxcllnt
Copy link

fwiw @zenparsing I agree with you on this one, and even acknowledging the aggregate years of Rx experience of the people involved in this spec, I don't think any of us are clever enough to assert changes to the Observer grammar like this will ultimately improve on what @headinthebox (cc: @mattpodwysocki @bartdesmet etc.) came out with years ago.

RxJS5, which by default deviates from previous versions in many ways, only changes default behaviors that were already available before. One would hope the standardization process would be a way to introduce and recast a formally defined concept to the aesthetics of the language, instead of a way to claim untested, arbitrary modifications are somehow more correct, ex cathedra.

In conclusion, I declare that Carthage must be destroyed.

@jordalgo
Copy link

@jhusain - Pardon my confusion but what will be the new behavior if an error is thrown during subscribe ? That error will now be caught and reported to the global scope (HostReportErrors) ? Or is that only if there is no observer error handler to pipe the error to ?

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 31, 2016

Errors thrown from observer notifications will be caught and reported to HostReportErrors, and will not close the subscription. This matches the behavior of Event Target and Iterable.iterate.

If the developer desires that the subscription be closed when there is an error in consumer code they can use forEach.

@jhusain
Copy link
Collaborator Author

jhusain commented Dec 31, 2016

For those who did not have the time to read this very long thread, @mattpodwysocki supports this change.

@benjamingr
Copy link

benjamingr commented Dec 31, 2016

I just want to point out that I'm fairly happy with the resolution of this issue - although I'm not very pleased with the discussion itself.

I think that these semantics are not only the most reasonable - they manage to support both Node's use cases and the browsers' which I don't see how any different semantics would support.

@trxcllnt
Copy link

trxcllnt commented Jan 1, 2017

@jhusain why would the method that creates a Promise, a type without cancellation, be the method that auto-disposes on errors? Have I slipped into the twilight zone without realizing? This still is in direct conflict with the issues raised here, which are essential to members of the node community.

@trxcllnt
Copy link

trxcllnt commented Jan 1, 2017

@jhusain For those who did not have the time to read this very long thread, @mattpodwysocki supports this change.

From my understanding, @mattpodwysocki's statement implied it's the observer's job to try/catch errors that might happen in next, and not the Observable's job to try/catch next and forward caught errors to the Observer's error handler. He's been consistent on this in a number of discussions.

I don't believe @mattpodwysocki has weighed in on whether he's for Subscribers not cleaning up if the Observer throws, which is really the more fundamental issue at stake here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests