Deprioritize cancellation in mergeSuppressed#3924
Conversation
Kover Report
|
|
I am divided on this one. On the one hand, the reasoning seems good. On the other hand, this seems to break the behavior of existing clients for a problem that hasn't been reported in practice. @nomisRev what are your thoughts? |
|
The important part is the Exception-above-cancellation thing (the |
|
I was looking at this already last night, and I am also divided... To be honest I was also divided when I originally wrote this code, but in the end I decided to be consistent with Diverging from this now also seems tricky because it might change behaviour in edge-cases, and I am a bit hesitant to do so unless Kotlin Std changes behavior of
This is another really good point. I also cannot find a similar YouTrack issue for Thank you @kyay10 for your extensive research. If we do not merge can we improve somewhere else i.e.
I do find this example quite troubling, and you make a good point here. "One might argue that it's on stdlib to fix AutoCloseable.use first, then we can follow suit. I think Arrow should still fix this behavior, just like how result { } doesn't catch fatal exceptions, even though stdlib's runCatching does." I need to sleep on this a couple more nights 😅 Some other opinions would be really valuable. |
|
I'll have to check this, but I recently tried code like: try { awaitCancellation() } finally { throw RuntimeException() }And it throws the runtime exception! You're right, though, this should probably be pushed into stdlib |
|
From conversation on Slack, it seems that if the code used |
|
To undermine my own argument: |
|
Here's the plan: I'll fix #3926 and #3925 in here as well. I'll still go with the proposed change, but only do it to that marker interface. In other words, behavior for Independently, I'll raise (pun intended) the cancellation swallowing issue to stdlib. If they decide to change their behaviour there, we can easily update our code. |
The problem
This affects all the "bracketing" functions from
fx, as well asresourceScopeandautoCloseScope. I'll be speaking aboutresourceScopefor brevity.The key observation behind this change is that some exceptions get swallowed because, e.g., someone
raised insideresourceScope, butonReleasethrew a real, non-fatal exception.I think that's unacceptable; code that completes exceptionally needs to be reported eventually.
Background
A band-aid solution would be to throw suppressed exceptions in
arrow.core.raise.fold, but I dislike that. Someone might want to catch the exception fromresourceScope, but instead, the exception is "tunnelled" all the way to thefoldcorresponding to theRaise. Such static tunnelling behaviour is unexpected for the dynamic exceptions we're used to.Even `kotlinx-coroutines` doesn't rethrow suppressed exceptions
One might argue that it's on stdlib to fix
AutoCloseable.usefirst, then we can follow suit. I think Arrow should still fix this behavior, just like howresult { }doesn't catch fatal exceptions, even though stdlib'srunCatchingdoes.There is prior art for this change; Scala's
Usinghas a more complicated priority system for exceptions. Importantly, they deprioritizeControlThrowable.I don't want to get into the nightmare of
ControlThrowablevsBreaktoo much, but I'm treatingCancellationExceptionas very similar toControlThrowable.History and Justification
Scala 2 treated
ControlThrowablespecially, similar to how we treatCancellationException. Scala 3 wanted to do away with that, and instead treatsBreaklike any other exception (in fact it's aRuntimeException). Needless to say, I disagree with that, with one reason being the issue at hand: some exceptions may get suppressed because of abreak/raise.Further, the entire Kotlin ecosystem treats
CancellationExceptionin a special way, and even Arrow treats it as a "fatal" exception for the purpose of not letting it get caught.Also, I think all
CancellationExceptions are being used for control flow, whether they'reRaiseCancellationExceptionor not. LMK if you disagree with this assertion, at least for how it's used in this PR!Proposed solution(s)
I've categorized this behaviour in terms of 4 "variants" of completion:
inlinefunction, of courseCancellationExceptionwas thrown. This includes coroutine cancellation andraiseWhen discussing
resourceScope, it's important to note thatonReleasecannot complete with areturn.I'm assuming, also, that
onReleasecompleting normally (i.e. withUnit) should not change the completion behavior ofresourceScopeat all. In other words, I assume thatonRelease { }is truly a no-op.Thus, we care about
onReleasecompleting with a cancellation or exceptionally.Importantly,
mergeSuppressedconflates normal and return completion with justnull. This behaviour can be easily changed (since one can usefinallyto detect non-local-returns). Just keep that in mind when reading the provided code.This PR has 8 tests for the behavior of
resourceScopeandonRelease, from the 4 completion types forresourceScopetimes the 2 interesting completion types foronRelease. "first" and "second" in the table cells refer to which completion is preferred ("first" being the one from the column, i.e. the completion ofresourceScope; second being the one from the row, i.e. theonRelease)Current behavior
Importantly, the main point of this PR is to change that second row so that it's as follows:
In other words, we seek to prioritize non-fatal exceptions above cancellation
The tricky part, however, is how to deal with that first row.
Proposed behavior
which results from the axiom:
raiseandreturnshould be treated the same.Axiom Justification
raiseand non-localreturndo the same thing: they let you jump to a statically determined, enclosing block of code. They're "static exceptions", if you will, or even "tunnelled exceptions", as they're called in literature. Even Ross Tate's lifetimes wish to do something similar toRaise(non-local returns becoming objects you can pass around), with so-called "Lexical Aborts" implemented using specially-tagged exceptions (which is howRaiseis implemented!).This axiom means that the
returnandcancelcolumns should be identical.Note that, in the proposed behavior, this means that a
raiseinonReleasetakes precedence over araiseinresourceScopeScala-like behavior
Scala disagrees on this point. In their view, equal-priority completions should always prefer "first". One can change this PR by swapping the order of
other is CancellationExceptionandthis is CancellationException, so thatthisis always prioritized.However, this results in the following behavior, which breaks the
raise-equals-returnaxiom:This can be fixed.
resourceScopecan treat non-local-returns specially, e.g. by producing a dummyCancellationExceptionto pass intomergeSuppressed, or a new, more-expressivemergeSuppressedcan be created. Regardless, it'd have the following consistent behavior:However, a new annoyance arises: normal completion is treated differently from non-local returns, even when code can look rather similar. For instance, consider the following:
Arrow is no stranger to such a difference;
accumulatemakes this distinction. Still, it can be surprising, and it's annoying to implement sincemergeSuppressedcan't easily accomodate it in its current form.Thus, I went with prioritizing
onRelease's cancellation/raise. I think it makes some sense, too, because of howtry-finallyworks normally. In particular, this interesting behavior happens:I.e.
finallyactually takes precedence. This is the case for exceptions as well, except when usinguseortry-with-resourcesin Java. Scala simply extrapolates from Java'stry-with-resourcesand says that all completion behavior should prioritisetryoverfinally; however, I think it makes sense to only apply such behavior to exceptions, and thus treat non-local returns and cancellation differently.