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

2.x: Strawman proposal for uncaught exception configuration #5569

Closed
wants to merge 1 commit into from

Conversation

ZacSweers
Copy link
Contributor

This is a strawman example/proposal for configurable uncaught exception handling in RxJavaPlugins. This is in ref to #5234.

The two options are to defer to current thread's handler (the current implementation and remains the default) and an optional throw option that wraps the throwable into an UncaughtRxJavaException for developers to watch for separately (and rethrowing, which requires some sort of RuntimeException wrapping).

If the proposal is agreeable/after API design is in place, I can add tests.

This is a strawman example/proposal for configurable uncaught exception handling in RxJavaPlugins. This is in ref to ReactiveX#5234.

The two options are to defer to current thread's handler (the current implementation and remains the default) and an optional `throw` option that wraps the throwable into an `UncaughtRxJavaException` for developers to watch for separately (and rethrowing, which requires some sort of `RuntimeException` wrapping).
@ZacSweers ZacSweers changed the title Strawman proposal for uncaught exception configuration [2.x] Strawman proposal for uncaught exception configuration Aug 26, 2017
@ZacSweers ZacSweers changed the title [2.x] Strawman proposal for uncaught exception configuration 2.x: Strawman proposal for uncaught exception configuration Aug 26, 2017
@codecov
Copy link

codecov bot commented Aug 26, 2017

Codecov Report

Merging #5569 into 2.x will decrease coverage by 0.03%.
The diff coverage is 50%.

Impacted file tree graph

@@             Coverage Diff              @@
##                2.x    #5569      +/-   ##
============================================
- Coverage     96.23%   96.19%   -0.04%     
- Complexity     5823     5825       +2     
============================================
  Files           631      632       +1     
  Lines         41421    41426       +5     
  Branches       5739     5740       +1     
============================================
- Hits          39861    39851      -10     
- Misses          616      625       +9     
- Partials        944      950       +6
Impacted Files Coverage Δ Complexity Δ
.../io/reactivex/plugins/UncaughtRxJavaException.java 0% <0%> (ø) 0 <0> (?)
.../main/java/io/reactivex/plugins/RxJavaPlugins.java 99.34% <66.66%> (-0.66%) 146 <1> (ø)
...ava/io/reactivex/processors/BehaviorProcessor.java 88.49% <0%> (-4.87%) 62% <0%> (ø)
...l/operators/observable/ObservableFlatMapMaybe.java 90.84% <0%> (-3.93%) 2% <0%> (ø)
.../internal/operators/flowable/FlowableInterval.java 94.44% <0%> (-2.78%) 3% <0%> (ø)
...nternal/operators/observable/ObservableWindow.java 98% <0%> (-2%) 3% <0%> (ø)
.../io/reactivex/disposables/CompositeDisposable.java 97.24% <0%> (-1.84%) 39% <0%> (-1%)
...rnal/subscriptions/DeferredScalarSubscription.java 98.46% <0%> (-1.54%) 28% <0%> (-1%)
...vex/internal/operators/flowable/FlowableCache.java 92.61% <0%> (-1.35%) 7% <0%> (ø)
...ernal/operators/flowable/FlowableFromIterable.java 95.18% <0%> (-1.07%) 5% <0%> (ø)
... and 21 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 994c65d...54ac1dd. Read the comment docs.

@akarnokd
Copy link
Member

There is a way without changing the plugins:

public class UncaughtCrash {
    
    @Before
    public void before() {
        RxJavaPlugins.setErrorHandler(e -> {
            Thread.currentThread().setUncaughtExceptionHandler((t, f) -> {
                Thread.currentThread().setUncaughtExceptionHandler(null);
                throw (InternalError)f;
            });
            throw new InternalError(e);
         });
    }
    
    @After
    public void after() {
        RxJavaPlugins.setErrorHandler(null);
    }
    
    @Test
    public void test1() {
        RxJavaPlugins.onError(new IOException());
    }
    
    @Test
    public void test2() {
        RxJavaPlugins.onError(new IOException());
    }
}

This will bypass the try-catch around the consumer invocation in RxJavaPlugins.

@ZacSweers
Copy link
Contributor Author

That's not a solution for application use though, which could interfere with crash reporting tools

@akarnokd
Copy link
Member

akarnokd commented Aug 26, 2017

interfere with crash reporting tools

How?

You can restore the previous handler:

    RxJavaPlugins.setErrorHandler(e -> {
        UncaughtExceptionHandler saved = Thread.currentThread().getUncaughtExceptionHandler();
        Thread.currentThread().setUncaughtExceptionHandler((t, f) -> {
            Thread.currentThread().setUncaughtExceptionHandler(saved);
            throw (InternalError)f;
        });
        throw new InternalError(e);
     });

or

    RxJavaPlugins.setErrorHandler(e -> {
        UncaughtExceptionHandler saved = Thread.currentThread().getUncaughtExceptionHandler();
        Thread.currentThread().setUncaughtExceptionHandler((t, f) -> {
            saved.uncaughtException(t, f);
            throw (InternalError)f;
        });
        throw new InternalError(e);
     });

@ZacSweers
Copy link
Contributor Author

ZacSweers commented Aug 27, 2017

The first solution will get stuck in a cycle of try/catching before eventually bombing out in CompositeException due to duplicates found in the chain (but not without creating an extremely deep trace). The decorator does get a chance in its catch clause, but not without the collateral above. If it adds any metadata, it is buried within hundreds of lines of stacktraces :/. This would present an immense problem for grouping in crash reporting tools as well

The second doesn't actually avoid the issue for an android environment because sending it to the saved handler first will just exit the process before the decorator has a chance to handle it in a catch clause again.

@akarnokd
Copy link
Member

Could you provide an example runnable test that demonstrates the way you want to catch these exceptions?

stuck in a cycle of try/catching

UncaughtRxJavaException would exhibit the same problem. Both need an extra case in the throwIfFatal helper.

/cc @JakeWharton

@ZacSweers
Copy link
Contributor Author

Can do. I'll be away from my computer tomorrow but will circle back here with it Monday

@ZacSweers
Copy link
Contributor Author

ZacSweers commented Aug 28, 2017

Here's a demo. https://github.com/hzsweers/rxuncaughtissue

Just click the button to see, result will be a long trace like this:

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.rxuncaughtissue, PID: 5469
    java.lang.RuntimeException: io.reactivex.exceptions.UndeliverableException: java.lang.RuntimeException: io.reactivex.exceptions.CompositeException: 2 exceptions occurred. 
        at com.example.rxuncaughtissue.MainActivity$2.accept(MainActivity.java:46)
        at com.example.rxuncaughtissue.MainActivity$2.accept(MainActivity.java:32)
        at io.reactivex.plugins.RxJavaPlugins.onError(RxJavaPlugins.java:355)
        at io.reactivex.Observable.subscribe(Observable.java:10910)
        at io.reactivex.Observable.subscribe(Observable.java:10889)
        at io.reactivex.Observable.subscribe(Observable.java:10767)
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:50)
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15)
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26)
        at android.view.View.performClick(View.java:5637)
        at android.view.View$PerformClick.run(View.java:22429)
        at android.os.Handler.handleCallback(Handler.java:751)
        at android.os.Handler.dispatchMessage(Handler.java:95)
        at android.os.Looper.loop(Looper.java:154)
        at android.app.ActivityThread.main(ActivityThread.java:6119)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)
     Caused by: io.reactivex.exceptions.UndeliverableException: java.lang.RuntimeException: io.reactivex.exceptions.CompositeException: 2 exceptions occurred. 
        at io.reactivex.plugins.RxJavaPlugins.onError(RxJavaPlugins.java:349)
        at io.reactivex.Observable.subscribe(Observable.java:10910) 
        at io.reactivex.Observable.subscribe(Observable.java:10889) 
        at io.reactivex.Observable.subscribe(Observable.java:10767) 
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:50) 
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15) 
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26) 
        at android.view.View.performClick(View.java:5637) 
        at android.view.View$PerformClick.run(View.java:22429) 
        at android.os.Handler.handleCallback(Handler.java:751) 
        at android.os.Handler.dispatchMessage(Handler.java:95) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 
     Caused by: java.lang.RuntimeException: io.reactivex.exceptions.CompositeException: 2 exceptions occurred. 
        at com.example.rxuncaughtissue.MainActivity$2.accept(MainActivity.java:46)
        at com.example.rxuncaughtissue.MainActivity$2.accept(MainActivity.java:32)
        at io.reactivex.plugins.RxJavaPlugins.onError(RxJavaPlugins.java:355)
        at io.reactivex.internal.observers.LambdaObserver.onError(LambdaObserver.java:77)
        at io.reactivex.internal.disposables.EmptyDisposable.error(EmptyDisposable.java:63)
        at io.reactivex.internal.operators.observable.ObservableError.subscribeActual(ObservableError.java:37)
        at io.reactivex.Observable.subscribe(Observable.java:10903)
        at io.reactivex.Observable.subscribe(Observable.java:10889) 
        at io.reactivex.Observable.subscribe(Observable.java:10767) 
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:50) 
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15) 
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26) 
        at android.view.View.performClick(View.java:5637) 
        at android.view.View$PerformClick.run(View.java:22429) 
        at android.os.Handler.handleCallback(Handler.java:751) 
        at android.os.Handler.dispatchMessage(Handler.java:95) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 
     Caused by: io.reactivex.exceptions.CompositeException: 2 exceptions occurred. 
        at io.reactivex.internal.observers.LambdaObserver.onError(LambdaObserver.java:77) 
        at io.reactivex.internal.disposables.EmptyDisposable.error(EmptyDisposable.java:63) 
        at io.reactivex.internal.operators.observable.ObservableError.subscribeActual(ObservableError.java:37) 
        at io.reactivex.Observable.subscribe(Observable.java:10903) 
        at io.reactivex.Observable.subscribe(Observable.java:10889) 
        at io.reactivex.Observable.subscribe(Observable.java:10767) 
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:50) 
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15) 
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26) 
        at android.view.View.performClick(View.java:5637) 
        at android.view.View$PerformClick.run(View.java:22429) 
        at android.os.Handler.handleCallback(Handler.java:751) 
        at android.os.Handler.dispatchMessage(Handler.java:95) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 
     Caused by: io.reactivex.exceptions.CompositeException$CompositeExceptionCausalChain: Chain of Causes for CompositeException In Order Received =>
        at io.reactivex.plugins.RxJavaPlugins.onError(RxJavaPlugins.java:359)
        at io.reactivex.internal.observers.LambdaObserver.onError(LambdaObserver.java:77) 
        at io.reactivex.internal.disposables.EmptyDisposable.error(EmptyDisposable.java:63) 
        at io.reactivex.internal.operators.observable.ObservableError.subscribeActual(ObservableError.java:37) 
        at io.reactivex.Observable.subscribe(Observable.java:10903) 
        at io.reactivex.Observable.subscribe(Observable.java:10889) 
        at io.reactivex.Observable.subscribe(Observable.java:10767) 
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:50) 
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15) 
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26) 
        at android.view.View.performClick(View.java:5637) 
        at android.view.View$PerformClick.run(View.java:22429) 
        at android.os.Handler.handleCallback(Handler.java:751) 
        at android.os.Handler.dispatchMessage(Handler.java:95) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 
     Caused by: java.lang.RuntimeException: This gets buried
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:49)
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15) 
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26) 
        at android.view.View.performClick(View.java:5637) 
        at android.view.View$PerformClick.run(View.java:22429) 
        at android.os.Handler.handleCallback(Handler.java:751) 
        at android.os.Handler.dispatchMessage(Handler.java:95) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 
     Caused by: java.lang.RuntimeException: Duplicate found in causal chain so cropping to prevent loop ...
        at io.reactivex.plugins.RxJavaPlugins.onError(RxJavaPlugins.java:359)
        at io.reactivex.internal.observers.LambdaObserver.onError(LambdaObserver.java:77)
        at io.reactivex.internal.disposables.EmptyDisposable.error(EmptyDisposable.java:63)
        at io.reactivex.internal.operators.observable.ObservableError.subscribeActual(ObservableError.java:37)
        at io.reactivex.Observable.subscribe(Observable.java:10903)
        at io.reactivex.Observable.subscribe(Observable.java:10889)
        at io.reactivex.Observable.subscribe(Observable.java:10767)
        at com.example.rxuncaughtissue.MainActivity.simulateError(MainActivity.java:50)
        at com.example.rxuncaughtissue.MainActivity.access$000(MainActivity.java:15) 
        at com.example.rxuncaughtissue.MainActivity$1.onClick(MainActivity.java:26) 
        at android.view.View.performClick(View.java:5637) 
        at android.view.View$PerformClick.run(View.java:22429) 
        at android.os.Handler.handleCallback(Handler.java:751) 
        at android.os.Handler.dispatchMessage(Handler.java:95) 
        at android.os.Looper.loop(Looper.java:154) 
        at android.app.ActivityThread.main(ActivityThread.java:6119) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776) 

@akarnokd
Copy link
Member

I see. Unless there is a free pass for the exception thrown, this PR would end up with the same complicated cause chain.

So if I understand correctly, you want the following two:

  1. synchronous sequences should crash and a non-reactive try-catch should be able to grab the exception,
  2. shim Observables installed via assembly hooks should be able to try-catch around an onError call to a downstream and grab an exception bouncing back from the onError handler.

The first case, when inside an unit test, could be simulated via the TestHelper.trackPluginErrors previously mentioned.

Otherwise and in option 2, the question is what would you even do about that undeliverable error?

Either way, you'd still have to get a free pass for that type of crash by modifying throwIfFatal helper.

And finally, even if such behavior override would be added, I'd go for a static volatile flag instead of a system parameter so it can be enabled and disabled when testing the feature itself.

@ZacSweers
Copy link
Contributor Author

The first case can definitely be handled in tests, but I'm basically trying to write a tool to help with crash reporting by decorating the observer with this observer that, in the event of the delegate observer throwing an OnErrorNotImplementedException, would throw its own exception with a bunch of metadata up before crashing.

What modifications would need to go into throwIfFatal? Even if it's something as simple as letting OnErrorNotImplemented bubble up, it'd go a long way to detecting unhandled errors and being able to tie them to the point of subscription.

Volatile flag sounds fine to me

@akarnokd
Copy link
Member

What modifications would need to go into throwIfFatal?

Another if with the RuntimeException or Error to be rethrown.

letting OnErrorNotImplemented bubble up

Not a good idea as it is an established exception type and unexpected new behavior of it.

in the event of the delegate observer throwing an OnErrorNotImplementedException

I'd think you are using the wrong hook then; setObservableOnSubscribe is called when subscribing where you could analyze the LambdaObserver for the default onError handler (it's a constant). For that, though, the LambdaObserver needs modification, perhaps by adding a public isDefaultErrorConsumer. This, however, would rely on otherwise discouraged access into the internal packages of RxJava.

Other option I see would be a new hook RxJavaPlugins.setOnErrorNotImplementedConsumer(Function<Consumer<Throwable>, Consumer<Throwable>>) where you could basically supply an onError handler to the LambdaObserver when subscribe() and subscribe(Consumer<T>) is invoked.

@ZacSweers
Copy link
Contributor Author

ZacSweers commented Aug 29, 2017

I think a key part of this is that it probably shouldn't be tied to LambdaObserver, since OnErrorNotImplementedException is public and developers could set their own observers with implementations that leverage it (and a throwing onError is the real case trying to be handled here). Custom observers are what we actually use as well, and what we would watch for in an onSubscribe hook to know when to decorate (as we wouldn't want to do it on all of rxjava's internal subscribes).

Not really sure how to proceed on this though :/

@ZacSweers
Copy link
Contributor Author

Actually, I think just knowing state about LambdaObserver would be suitable. It's probably not an unsafe assumption that if someone is actually implementing onError, they are responsible for their own implementation.

@ZacSweers
Copy link
Contributor Author

Any further thoughts on exposing LambdaObserver state?

@akarnokd
Copy link
Member

akarnokd commented Sep 5, 2017

Modifying LambdaObserver (and all the other types) sounds like an acceptable tradeoff.

@ZacSweers
Copy link
Contributor Author

Cool. I can start working on a PR. With regards to it being internal currently, would you want to make it public with isDefaultErrorConsumer then? Or maybe a higher level interface that it just implements while keeping the implementation internal?

@akarnokd
Copy link
Member

akarnokd commented Sep 5, 2017

Implementing an interface simplifies the detection of the feature but otherwise will make a method public on LambdaObserver anyway.

I'd say let's add a HasDefaultErrorConsumer interface into the io.reactivex.observers package.

@ZacSweers
Copy link
Contributor Author

Sounds good, I'll start on that

@ZacSweers ZacSweers closed this Sep 5, 2017
@ZacSweers ZacSweers deleted the z/uncaughtBehavior branch September 5, 2017 11:22
ZacSweers added a commit to ZacSweers/RxJava that referenced this pull request Sep 6, 2017
Followup from ReactiveX#5569, and allows you to introspect if the resulting observer has missing error consumption and subsequently supplies a default (throwing) one.
@ZacSweers
Copy link
Contributor Author

Continued in #5590

akarnokd pushed a commit that referenced this pull request Sep 12, 2017
* Implement HasDefaultErrorConsumer

Followup from #5569, and allows you to introspect if the resulting observer has missing error consumption and subsequently supplies a default (throwing) one.

* Add `@since`

* Add tests

* Add support in relevant completable observers

* Add support in ConsumerSingleObserver

* Add support in MaybeCallbackObserverTest

* Add support in LambdaSubscriber

* Switch to CompositeObserver and onErrorImplemented()

* Update wording to use Introspection

* Update tests and flip implementation logic to match naming
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants