kotlinx.coroutines: SendChannel.offer should never throw
As of version 1.1.1, if you call offer from the non blocking world (which doesn’t handle CancellationException by default, on a closed Channel, an exception is thrown, which by default will crash the program.
This is a problem in several ways:
offerwhich returns aBooleanper its signature actually is kind of a “tri-lean” as if the channel is closed, it will not returnfalsebut throw.offeris possibly called from a thread that is not the one where the callback that offers the values is unregistered, so race conditions may implyofferbeing called once or a few times after the channel is closed, and usually, the intention is to ignore those. Simply returningfalseinstead of throwing would likely be a better behavior.- There’s no way to
offerwithout having this function possibly throw, because by the timeisClosedForSendreturnsfalse, it may have been closed due to concurrent execution, so a subsequent call toofferwould throw.
My current workaround is the following:
fun <E> SendChannel<E>.offerCatching(element: E): Boolean {
return runCatching { offer(element) }.getOrDefault(false)
}
About this issue
- Original URL
- State: closed
- Created 5 years ago
- Reactions: 24
- Comments: 65 (47 by maintainers)
Commits related to this issue
- Workaround offer throwing on closed channel Will fix rare crashes caused by uncaught exception that could be throw from the onConnectionStateChange method in GattConnectionImpl. Related to https://g... — committed to Beepiz/BleGattCoroutines by LouisCAD 5 years ago
- Implement ObservableValue<T>.asFlow() — committed to Kotlin/kotlinx.coroutines by dkhalanskyjb 4 years ago
- catch offer exceptions. See https://github.com/Kotlin/kotlinx.coroutines/issues/974 — committed to martinbonnin/apollo-kotlin by martinbonnin 4 years ago
- catch offer exceptions. See https://github.com/Kotlin/kotlinx.coroutines/issues/974 (#2007) — committed to apollographql/apollo-kotlin by martinbonnin 4 years ago
- catch offer exceptions. See https://github.com/Kotlin/kotlinx.coroutines/issues/974 (#2007) (cherry picked from commit 9e2a24f43c9c74a3912e87cd1c1389c061838038) — committed to apollographql/apollo-kotlin by martinbonnin 4 years ago
- Introduce trySend and tryReceive channel operations as a future replacement for error-prone offer, poll and receiveOrNull Fixes #974 — committed to Kotlin/kotlinx.coroutines by qwwdfsad 3 years ago
- Introduce trySend and tryReceive channel operations as a future replacement for error-prone offer, poll and receiveOrNull Fixes #974 — committed to Kotlin/kotlinx.coroutines by qwwdfsad 3 years ago
- Introduce trySend and tryReceive channel operations as a future replacement for error-prone offer, poll and receiveOrNull Fixes #974 — committed to Kotlin/kotlinx.coroutines by qwwdfsad 3 years ago
- Introduce trySend and tryReceive channel operations as a future replacement for error-prone offer, poll and receiveOrNull Fixes #974 — committed to Kotlin/kotlinx.coroutines by qwwdfsad 3 years ago
- Deprecate SendChannel.offer and ReceiveChannel.poll, replace their usages along the codebase (#2644) * Deprecate SendChannel.offer and replace its usages along the codebase * Deprecate ReceiveChanne... — committed to Kotlin/kotlinx.coroutines by qwwdfsad 3 years ago
- chore: Use trySend() instead of offer() See https://github.com/Kotlin/kotlinx.coroutines/issues/974 — committed to barbeau/while-in-use-location by barbeau 3 years ago
- Introduce trySend and tryReceive channel operations as a future replacement for error-prone offer, poll and receiveOrNull Fixes #974 — committed to pablobaxter/kotlinx.coroutines by qwwdfsad 3 years ago
- Deprecate SendChannel.offer and ReceiveChannel.poll, replace their usages along the codebase (#2644) * Deprecate SendChannel.offer and replace its usages along the codebase * Deprecate ReceiveChanne... — committed to pablobaxter/kotlinx.coroutines by qwwdfsad 3 years ago
We’ve studied our channel API surface and tried to work towards not only with the new method name instead of
offer, but rather with a consistent naming scheme that can be further used in other libraries or coroutines API. Here’s a compressed reasoning path for the new naming.When looking at the new API with TBD names, whether it’s “catching offer” or “receiveOrClosed”, the very first thing that comes to mind is that these methods try to do the same thing – error encapsulation. Other languages have built-in constructs for that, e.g.
tryfunction in Go andtry!macro in Rust (NB: replaced with trailing?in the latest releases). A similar convention is adopted in Java as well:Spliterator.tryAdvanceorIterables.tryFindare good examples of that.So, maybe just adding
tryas a method prefix would suffuce:tryOffer,trySend,tryReceiveandtryPoll. It could’ve worked, but it introduces new problems and ambiguities:offer, so eventually channels will havetryOffer, but notoffer. That’s understandable for people who use coroutines for a while, but not that clear for newcomers, and the general idea will be much harder to grasp for themLock.tryLockis the biggest example here. Now having API that is both suspendable and not, it’s easy to get thinking that “trySend” is just a “send” without suspension. (or even worse, doesDeferred.tryAwaitsuspend?)Orthogonally, Kotlin has
Resultclass for encapsulating either successful or failed result, and*Catchingfunctions to encapsulate computation and its result. Moreover, we lift the restrictions regardingResultusages in Kotlin 1.5 and expect people to use it even more. The only downside is a bit too long name that may be not so convenient.Taking it into account and the fact, that
offerandpollare legacy Java-isms (attempt to mimic queue interface), we decided to stick with the following:send,receive.trySendandtryReceiveinstead ofofferandpoll.catching. For now we only providereceiveCatchingandonReceiveCatching(instead of previously internalreceiveOrClosed)kotlin.Result(e.g.Deferred.awaitCatching) or domain-specific result class. For channels it isChannelResult.We still willing to fix it in 1.4.x
I’ve been bitten by that again just now (plus earlier)!
I know a bunch of code using
callbackFlow/channelFlowin the wild is unsafe as well becauseofferthrows as soon as the channel is closed, which happens before the block forawaitCloseis executed.That causes problems as the unregister can happen after the channel is closed if it’s not taking place on the same thread and
awaitCloseis used (which is often the case for both).Please, can you integrate a safe alternative to that before Kotlin 1.4?
It’ll be almost one year I reported this issue in less than a month.
There’s 4 other possibilities if they don’t try to understand what experimental stands for in this particular case:
freeCompilerArgsso they don’t see the warning.Disclaimer: These might all be bad ideas 😄
I think I prefer
offerovertrySendnaming because it doesn’t bring confusion withsend, which has a very different behavior.So, my proposal is to add an overload with the following signature:
offer(e: Element, throwIfClosed: Boolean), and deprecateoffer(e: Element), with aReplaceWithclause set tooffer(e, throwIfClosed = true).That avoids instantiating a sealed class, and that still makes developers upgrading kotlinx.coroutines notice the previous
offercalls could have been throwing.What do you think? 👍 or 👎
I have had several bugs in production because of this, it’s especially bad in Android where it will likely crash the app and since Kotlin doesn’t have checked exceptions it’s very easy to forget even if you’ve been bitten before. I agree that it doesn’t seem very likely that anyone is relying on this behavior.
You could always fork this library and remove the experimental annotations in your fork, then use that. Then nothing will change by surprise, ever! You just then have to keep pulling changes from this repo into your own, but you’d have full control over what changes to bring in.
I would not recommend this approach generally (because, as Louis said, there’s no real reason to not use experimental APIs in something that’s not a library), but if you have very strong restrictions around API stability, it’s one potential solution.
Yes, we are aiming to solve it in 1.4.
Please postpone the PR: we neither have resources right now to properly review it in a reasonable timeframe nor ready to do a thoughtful design meeting (to align potential
trySendwith potentialreceiveOrClosed,tryReceiveand similar pattern inkotlinx-datetime).I’ll keep you updated when we start discussing it.
@eygraber Non-trowing
offerseems to be what many people expect by analogy fromBlockingQueue, so the only way out I see here is to deprecate it.Here’s a better replacement plan. Let’s introduce a single
trySendfunction that returnsTrySendResultsealed class with 3 cases:Success,Failure, andClosed. We’ll addifClosedinline extension to this result class and will suggesttrySend().ifClosed { throw it }as anofferreplacement. That makes the throwing nature of the originaloffertotally explicit. You’ll be able to fix the throwing by simply removing.ifClosed { throw it }part.This is only my two cents, but I think it’s more likely that people have been bitten by
offerunexpectedly throwing like I have than people having found the current behavior useful and having relied on it. Also, I think people would have relied onofferthrowing would read the release notes before migrating to a newer version of kotlinx.coroutines (although it’s arguable it could happen from under with a transitive dependency if they’re lagging behind), more so since usingChannels currently often implies using@ObsoleteCoroutinesApiannotated symbols, which add warnings unless you opt-in.That’s why I think adding
offerOrClosedand makingofferno longer throw when the channel is closed, but instead returnfalsewould be an acceptable resolution of that issue, which possible negative impact should be limited, if any.The benefit to addressing this issue that way IMHO are that
Channels API would be simpler and have a more predictable behavior, giving you the choice on how you deal with closed channel (and avoiding possible crashes when callingofferout of suspending functions, that are hard to debug in my experience).We can actually have a slightly different proposal in mind:
where a
ValueOrClosed<T>is a special class we are going to introduce that contains either a value of typeTor a closed token.See also #330
UPDATED: It will have to be a
sealed class, but we can use inline function here:Then current
offer(x)call gets cleanly replaced with:It is Ok that it is long. The best part is that you immediately see what it does without looking at the docs.
I strongly vote for a domain specific class,
kotlin.Resultis too generic to be used in every specific method.I am not really happy of
*Cathingconvention, I don’t have a better proposal but I have to admit thatreceiveOrClosedsounds good for me.@qwwdfsad Could you please clarify if this issue is fixed in 1.4.0, which was released today?
Status update: this issue is not abandoned and will be fixed along with Kotlin 1.4 companion release (either it will be kotlinx.coroutines 1.3.8 or kotlinx.coroutines 1.4-M1).
Unfortunately, it is not possible to release it earlier due to KT-27524, that is fixed in 1.4
It just shows a proposed API (no impl)
Hm. That’s an interesting idea!
I agree with @elizarov (here) and @zach-klippenstein (here).
In a -unlikely- future, we should expect also
tryReceive(): ValueOrClosed@LouisCAD, did you consider this implementation?
Success/Failureusually imply lack of or presence of some error, which could be confusing withClosedwhich would be returned if the channel actually had an error. How about calling themAccepted/Rejectedinstead?There’s a lot going on here and
offerthrowing an exception really seems to be a problem people are getting bitten by. How’sofferdifferent fromsendand how we came to introduce it?sendis suspending, so it can only be used in a suspending context where anyCancellationExceptionis always considered “normal” and ignored.offerwas designed to be a non-suspending sibling tosend, so it was designed to behave in the same way. However, we’ve failed to foresee that it is going to be used a lot in non-suspending callbacks where aCancellationExceptioncauses some real issues.We can do a “quick-fix” by providing a replacement with essentially the same boolean-returning signature (for cases where you don’t care) that never throws. But how we should name it? The only good name I can come up with is
trySend.But what do we do with
offer? As pointed out by @pacher it is not correct to simply deprecate theoffer, since it is useful in actors are suspending, will properly handleCancellationExceptionand need to be terminated promptly (without cancellation exception anoffer-ing loop will just work forever). I suggest to deprecate anoffer(since it’s very short name is non-explicit about its throwing behavior) and replace it with more explicitly namedtrySendOrThrow.What do you think?
Please don’t change the semantics of existing API without major version change and/or deprecation cycle!
I totally agree that
offerthrowing on close is easy to overlook and seems annoying to handle at the first glance. But once you learn it, you start to use it and appreciate it. I am for one relying on this behavior.For example think of actors or any other process on the other side of the channel:
offerreturns false means it’s all good and well, but the actor is too busy at the moment (mailbox is full) and sending to the channel will block/suspend. I want to know that, so that I can choose what to do in this case, that is why I useofferinstead ofsendin the first placeofferthrows meaning that actor has died for some reason and I need to handle it!These are two totally different scenarios and execution paths. If the semantics would silently change overnight combining the two, it will be much harder for me to detect and fix malfunctioning system pretending to be fine than to deal with loud and clear explicit exception. Failing fast is so much better than incorrect behavior, always.
Currently
actorbuilder returnsSendChannel, so this exception is the only way to detect problems on the other side as a client. If you think about it, present semantics ofoffertotally make sense, concise and good.But
sendwill throw exactly the same exception if the channel is closed, how is it different? That’s the thing, we currently haveoffer,sendandsendBlockingwith consistent behavior. In my opinion either all of them should throw an exception or none of them.I am all for introducing safer functions and very much like the
offerOrClosedidea. But please add, don’t breakReturning inline classes is still experimental and will remain experimental in 1.4, so we will not be able to introduce a stable function returning
Result. The same problem withxxxOrClosevariants we’ve originally conceived (they are all designed to returnValueOrClosedinline class).@eygraber I’m not quite getting how you’ve counted 3 functions. What would be the difference between “one that is meant for use in a suspending context” and “one that is meant for non-suspending context that can throw a CancellationException”. They are the same in my mind. That’s what I’ve offered (pun intended) to call
trySendOrThrow.I’ve just been bitten by that too (See https://github.com/apollographql/apollo-android/issues/2005 for some context). The
offerkdoc mentions the exception but I missed it since I based my code on thecallbackFlowsnippet.I’ve opened a PR there to improve the aforementioned snippet.
Now that the
OrClosedfunctions made it to a release, can this be addressed?