Skip to content

Lossless cancelation and joinOrCancel - #4641

Open
reardonj wants to merge 11 commits into
typelevel:series/3.xfrom
reardonj:4620-cancelable
Open

Lossless cancelation and joinOrCancel#4641
reardonj wants to merge 11 commits into
typelevel:series/3.xfrom
reardonj:4620-cancelable

Conversation

@reardonj

@reardonj reardonj commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Context

There are a number of open bugs relating to data loss during races:

Fundamentally, these all must occur because there is no way to do all of the following together:

  • start a fiber
  • observe cancelation
  • cancel the started fiber
  • return the result of the started fiber if it completes before it gets canceled

In particular, the fiber that started the other fiber doesn't know it's getting canceled until it observes cancelation (ie. onCancel runs). At this point it is no longer possible for the fiber to complete, it must cancel. So all it can do is terminate its child fibers and carry on1.

I had previously tried to solve this problem with a new onCancelRequested combinator (#4633 ), but that would break other Fs. This implementation instead follows @djspiewak's suggestion to base the solution on an @armanbilge's cancelable fix in #3491 which would give cancelable different behavior in IO, but also leave a working (but not ideal) implementation for other Fs already.

New Behaviour

Unfortunately, we still need the old cancelable behavior, as it is needed to cancel blocking operations on a fiber (as in literally F.blocking). This design only works on a suspended fiber. What we can do, while letting other Fs remain no more broken than they are today, is implement onCancelRequested as onCancel by default, then in IO implement it by masking the operation, so it can only be canceled by the onCancelRequested effect.

By adding this behavior, we can run some finalizers before the fiber observes cancelation. Now we can give the operation a chance to return a result before the fiber is canceled and unable to return a result, but also try canceling the operation to make sure cancelation does actually happen if we can't complete.

Changes to Use This Behaviour

Unsafe usages of join.onCancel(cancel) are replaced with usages of joinOrCancel so that they can become lossless in IO. In IO, joinOrCancel will result in exactly one of getting outcome of the fiber or the fiber being canceled2. onCancelRequested remains lossy by default, but this is unfixable without new semantics which would break compatibility.

The implementation of cancelable is updated to use onCancelRequested.

The implementation of fromCompletableFuture is also updated to use onCancelRequested instead of it's bespoke cont implementation. This implementation is no better by default, but will no longer lose data in IO.

Implementation

IOFiber

onCancelRequested in IO is now handled as a new primitive IO.OnCancelRequested class. This operation introduces a second finalizer (referred to as acks to differentiation from the actual finalizers) stack to IOFiber. When the fiber receives a cancelation request, all acks are immediately run in parallel and any acks that get added after this point are also immediately started. This behavior is intended to drive the fiber towards cancelation as quickly as possible, with the expectation that acks are safe to run at any time, unlike finalizers, which clean up resources. The fiber waits for all acks to complete before running finalizers, since the acks could depend on a resource that will be disposed by a finalizer. If the IO.OnCancelRequested would come off the stack, the fiber will also wait on the ack to complete3

IO.racePair is also updated to use onCancelRequested instead of async cancelation so it does not lose data during a race, following @armanbilge's earlier implementation.

Footnotes

  1. You can do a little better if you know the fiber is returning a resource, and clean up that resource, but you still can't get data back out.

  2. or non-termination, of course.

  3. The ack should already be completed, since the ack cancelable action should

reardonj and others added 5 commits July 16, 2026 22:02
The sys.error call is a side-effect and should be suspended in IO
- add polling cancelable. This is needed to safely start the join in `joinOrCancel` without introducing a cancelation boundary that could drop an already started fiber
- replace unsafe usages of `join.onCancel(cancel)` construct with `joinOrCancel`
- replace fromCompletableFuture with an implementation that uses cancelable
In IO, cancelable can be implemented without hoisting the operation to a separate thread, by invoking the callback when cancelation is requested. Partly based on Arman's previous attempt in typelevel#3491

Co-authored-by: Arman Bilge <armanbilge@gmail.com>
IO.racePair has to be uncancelable because cancelable introduces a cancelation boundary when used unmasked
This was referenced Jul 18, 2026
@reardonj
reardonj marked this pull request as draft July 29, 2026 15:33
@reardonj

reardonj commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

I have tested this on the http4s test suite. Getting timeouts in org.http4s.ember.server.EmberUnixSocketSuite. Haven't traced it down. Though I do suspect thecanceling handlers should only be triggered it cancelation is unmasked (or at least, would be unmasked in the non-intrinsic version)

@reardonj

Copy link
Copy Markdown
Contributor Author

Narrowed issue down to fs2-io. Unix socket tests in there fail with the changes.

reardonj added 2 commits July 30, 2026 22:02
- cancelable needs to be on a separate fiber since it is for blocking operations which do not suspend the fiber, so we cannot start the acks
- onCancelRequested becomes a synonym for onCancel by default. In IO, onCancelRequested masks. This lets the implementation keep the `poll(fa).onCancelRequested(ack)`, which will at least cancel in other Fs, but lose data, while working correctly in IO.
- cancelable now uses onCancelRequested instead of onCancel so that cancelable operations get a chance to terminate.
The current onCancelRequested doesn't work with it. It looks like it should be fixable without it, by waiting to transition to Unevaluated until the fiber completes.
@reardonj reardonj changed the title Lossless cancelable and joinOrCancel Lossless cancelation and joinOrCancel Jul 31, 2026
Needed to preserve bin-compat.
@reardonj
reardonj marked this pull request as ready for review July 31, 2026 03:24
@reardonj

Copy link
Copy Markdown
Contributor Author

Revised things, and updated the main description. I've broken back out a separate onCancelRequested method for the on-request cancelation, which is now use by regular cancelable. FS2 needed cancelable to work with F.blocking.

@reardonj

Copy link
Copy Markdown
Contributor Author

To make joinOrCancel work, it has to take a poll, which is pretty awful. I'm not convinced the method actually pulls its weight, as it isn't sufficient to fix the cancelation hole.

reardonj added 2 commits July 31, 2026 18:28
- fix up naming issues
- restore cancelable tests
- adjust scaladoc
@djspiewak

Copy link
Copy Markdown
Member

Unfortunately, we still need the old cancelable behavior, as it is needed to cancel blocking operations on a fiber (as in literally F.blocking). This design only works on a suspended fiber. What we can do, while letting other Fs remain no more broken than they are today, is implement onCancelRequested as onCancel by default, then in IO implement it by masking the operation, so it can only be canceled by the onCancelRequested effect.

I don't really understand this. Why does this mean the old behavior is required?

@reardonj

reardonj commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Unfortunately, we still need the old cancelable behavior, as it is needed to cancel blocking operations on a fiber (as in literally F.blocking). This design only works on a suspended fiber. What we can do, while letting other Fs remain no more broken than they are today, is implement onCancelRequested as onCancel by default, then in IO implement it by masking the operation, so it can only be canceled by the onCancelRequested effect.

I don't really understand this. Why does this mean the old behavior is required?

F.blocking doesn't semantically block. The fiber is still 'running' (as in suspended.get() == false in IOFiber), but the thread is blocked on whatever it is doing. This means the external canceler cannot gain control of the runloop. I don't believe we can start the onCancelRequested/cancelable finalizers for a fiber safely unless the thread doing so has control of the runloop (i.e. via resume()). Attempting to do so will result in a race condition. Consider if we implemented it so we started the finalizers without control of the runloop (a la Arman's PR):

  1. fiber A starts useing a Resource
  2. fiber A registers a onCancelRequested finalizer which uses the resource
  3. fiber A starts a thread blocking operation
  4. fiber B requests cancelation of A, finds it blocked, proceeds to run the onCancelRequested finalizer on its fiber.
  5. before B actually runs the finalizer, fiber A completes the blocking operation, and cleans up the resource
  6. fiber B continues and runs the finalizer. Resource has leaked.

My solution avoids this by requiring control of the runloop to start the finalizers, and completes them before running any onCancel finalizers. But this means it won't work while the thread is blocked. So the separate fiber used by cancelable is still needed for the thread blocking case. However, cancelable can be implemented with onCancelRequested instead of onCancel to plug the original leak in #3474 .

It's probably possible to write an algorithm to safely perform step 3, but that's going to have to be very carefully designed to avoid races. IMO, that's not worthwhile for the relatively few pieces of code that need cancelable.


case 9 => succeeded(Left(error), depth) // attemptK

case 10 => // onCancelRequestedFailureK

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 is lost? error inside an onCancelRequested region permanently leaks a mask

succeeded(Right(result), depth)

case 10 => // onCancelRequestedSuccessK
masks -= 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decrement before acks.pop()

at the outermost mask level the ack-join is thrown away by shouldFinalize() at the top of runLoop and fiber.cancel returns before the acknowledgement finishes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decrement before acks.pop()

I'm not following the concern here. masks should only be used by runloop code, so why does the order matter? This code is called if the inner operation completed, at which point the ack should no longer matter.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, no race, and the body's result doesn't care.

But the next runloop iteration reads masks before running the IO we just returned: startedAcks implies canceled, so if the decrement takes masks to 0 then shouldFinalize() is true and the ack join gets dropped as _cur0. The ack fiber keeps running, nobody waits for it, and with no finalizers fiber.cancel returns while it's still going. The acks-before-finalizers drain can't save it either, the ack is already off the stack.

Nothing in the lib hits this??, all call sites are inside uncancelable.
Public onCancelRequested is. Pushing UncancelableK before acks.pop().as(result) would hold the mask across the join, and the failed twin needs the same ordering.

I think I can try to test spec this if it helps.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, I see. I didn't consider that scenario. Well, if the onCancelRequested operation succeeded, the ack completion doesn't matter any more, so I doubt any CE code hitting it would encounter an issue. Outside of some async tests, it's all fiber cancellation, and those cancellations are going to no-op if we got this far.

It could be an issue though, since a finalizer could then clean up a resource the ack depends on (this is the main reason tracking acks is important).

I think I can try to test spec this if it helps.

Sure, I'm not going to have a chance to fix this today.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are 2 small tests, should fail, but please recheck - I'm bit lost a track trying to repro
reardonj#1


// otherwise it is too late to request cancelation
if (!finalizing) {
masks += 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"UnmaskRunLoop" only unmasks when masks == cur.id
any enclosing poll used inside an "onCancelRequested" body silently does nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. It doesn't really make sense to poll inside an onCancelRequested body, since the construct exists to wrap code that can't safely be canceled, and instead needs to use its own cancelation protocol.

To make this whole thing kind of function without a new major release, the default implementation of onCancelRequested in Spawn just uses regular cancellation. So, the user-land code does wrap the part than can be canceled in poll, so that it can be canceled in Spawn, but for the better version of onCancelRequested in IO, the poll has to be ignored so the ack can actually run.

Yes, this is awful. I don't see any other way out that doesn't involve a breaking binary compatibility change though.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then it makes sense,

IMHO but worth documenting - the behaviour differs by instance: uncancelable(poll => poll(never).onCancelRequested(fin)) hangs on cancel under IO (I've seen in your tests assertation), while under Kleisli[IO, R, *] it hits the default fa.onCancel(ack), poll stays live, and it cancels.
same joinOrCancel -> takes a Poll that does nothing at IO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while under Kleisli[IO, R, *] it hits the default fa.onCancel(ack), poll stays live, and it cancels.
same joinOrCancel -> takes a Poll that does nothing at IO.

Oh, well that's bad. I guess it would need explicit delegation set up?

I have been holding off on expanding on documentation until we decide this is even the way we want to fix the underlying problem with cancellation, and if the awful default is acceptable to start with.

}
}

onCancelRequested(poll(wait), void(delay(cf.cancel(true))))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Canceling fromCompletableFuture/fromCompletionStag will yieldOutcome.Errored(CancellationException) instead of Canceled(). IMHO - contract change not sure is it a real issue.

}
def fromCompletableFuture[A](fut: F[CompletableFuture[A]]): F[A] =
uncancelable { poll =>
flatMap(fut) { cf =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats a change, the poll around the acquisition was dropped (G.flatMap(poll(lift(fut))) → flatMap(fut)), so producing the CompletableFuture is uncancelable?

/**
* Suspend a `java.util.concurrent.CompletableFuture` into the `F[_]` context.
*
* @note

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be at least changesd,

AsyncPlatform.scala:55 - Before ifM on cf.cancel(true)'s return value is gone, so instances that don't override onCancelRequested get fire-and-forget on a CF that refuses cancelation.

@stasimus stasimus Aug 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been holding off on expanding on documentation until we decide this is even the way we want to fix

I 've seen the comment after I already reporte, but anyway is it desirable change?

Comment thread tests/shared/src/test/scala/cats/effect/std/MutexSuite.scala
case Left(Outcome.Succeeded(code)) => code
case Right(Outcome.Errored(t)) => IO.raiseError(t)
case Right(_) => sys.error("impossible")
case Right(_) => IO.delay(sys.error("impossible"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this statement is not true anymore and reachable.

survives only because shouldFinalize() discards the suspended delay first

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If needed I can spend time crafting the test - I may be wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been a while since I looked at this, but iirc, it ends up being canceled before the sys.error executes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please look into test
reardonj#2

@djspiewak

Copy link
Copy Markdown
Member

So I had a think about this, and I think it's still possible to make a primitive cancelable, but the wonky trick is that the finalizer actions must be run by the calling fiber. So in other words, it's a strange sort of ditty where the actions are being registered for invocation in some other runloop. Note that this is insanely still compatible with the default cancelable implementation, right down to the non-volatile var publication semantics, because the default cancelable already pushes the body into its own fiber! Basically, the only difference with the primitive cancelable is, for efficiency, rather than creating a new child fiber and running the actions in the parent, it would reuse the canceling fiber to run the actions while keeping the body in the main fiber.

This is definitely all sorts of weird when you think about exclusivity, too. For example, what happens if we start running the action and then the body completes? Do we interrupt it? Error out? Do we prevent the body from completing? Do we block the cancelee? In all these cases I think we can just mimic what the default implementation does and avoid getting too fancy: no exclusivity, no blocking, it's a best effort fire-and-forget.

@stasimus

Copy link
Copy Markdown
Contributor

Or alternative

Give cancelable one atomic with three states: Unclaimed | ClaimedByCanceler | CompletedByBody.

  1. Canceler tries resume() first, if that works, the fiber runs its own acks as in this PR.
  2. If not (fiber is running, maybe in F.blocking), the canceler CASes to ClaimedByCanceler and schedules fin on the cancelee's EC, with the cancelee's IOLocals snapshotted at registration. So fin sees the same context.
  3. When the body finishes, the runloop CASes to CompletedByBody. If it wins, the canceler's claim fails and fin never runs against a completed body. No leak.
  4. If it loses, the runloop waits for fin to finish before moving on, like join does. Resources can't be released while fin is running.

The canceler only schedules fin, it never runs user code inline.

@reardonj

Copy link
Copy Markdown
Contributor Author

This is definitely all sorts of weird when you think about exclusivity, too. For example, what happens if we start running the action and then the body completes? Do we interrupt it? Error out? Do we prevent the body from completing? Do we block the cancelee? In all these cases I think we can just mimic what the default implementation does and avoid getting too fancy: no exclusivity, no blocking, it's a best effort fire-and-forget.

My concern with fire-and-forget is the interaction with resource cleanup. If the canceled fiber can continue running while the action is running, the canceled fiber could clean up a resource that the action depends on. Irrelevant for Fiber, but maybe an issue for using this for io_uring?

@reardonj

reardonj commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@stasimus , I think the coordination is a bit more complicated.

Or alternative

Give cancelable one atomic with three states: Unclaimed | ClaimedByCanceler | CompletedByBody.

  1. Canceler tries resume() first, if that works, the fiber runs its own acks as in this PR.

  2. If not (fiber is running, maybe in F.blocking), the canceler CASes to ClaimedByCanceler and schedules fin on the cancelee's EC, with the cancelee's IOLocals snapshotted at registration. So fin sees the same context.

I'm a little iffy on trying to acquire 2 separate CASes in sequence, but maybe it doesn't matter if the runloop gets suspended in between.

  1. When the body finishes, the runloop CASes to CompletedByBody. If it wins, the canceler's claim fails and fin never runs against a completed body. No leak.

In this case, the runloop would CAS into CompletedByBody, take fin off the stack, then revert to Unclaimed? We'd still need to current checks to run the cancelable fins so that if there are multiple fins on the stack and the runloop wins the CAS, we still start the next one.

  1. If it loses, the runloop waits for fin to finish before moving on, like join does. Resources can't be released while fin is running.

If it loses, the runloop first has to wait for the right fin to exist. In the current implementation, I'm replacing the stack of finalizers with a stack of IOs that are joining the running finalizer, so that's awkward. Maybe I could encode the finalizers as IOFibers instead. Then we just need to schedule the fiber, and can join on it whenever.

The canceler only schedules fin, it never runs user code inline.

@stasimus

stasimus commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

@reardonj

I'm a little iffy on trying to acquire 2 separate CASes in sequence

They don't have to be acquired together. resume() is just the existing question of who owns the runloop, and the canceler only touches the ack state after resume() has already failed, so it does one CAS. Losing resume() and winning the claim is a well-defined outcome (the fiber is off in blocking, so we schedule fin ourselves), and losing both is fine too, the cancelee will get to it.

the runloop would CAS into CompletedByBody, take fin off the stack, then revert to Unclaimed?

My fault for writing "one atomic", I meant one per cancelable node, not one per fiber. Then nothing reverts. A node goes Unclaimed -> Started or Unclaimed -> Done once and stays there. Traversal doesn't change, keep the current checks, the per-node state only decides whether that one ack runs.

Maybe I could encode the finalizers as IOFibers instead.

That's better than what I had, and it removes the "wait for the right fin to exist" problem. You could go a step further and let the state word be the fiber: an AtomicReference per node holding Unclaimed | IOFiber[Unit] | Done. The canceler builds the fiber and CASes it in, the runloop CASes to Done when the body completes, and whoever loses reads what the winner left behind. So you can't observe Started without already having the fiber to join, and nothing gets allocated on the path where cancelation never happens.

One thing I'm unsure about: runAcknowledgement currently passes the cancelee's localState as of cancelation time. If the canceler is building the fiber it can't read that safely, so it would have to be snapshotted at registration instead, which is a slightly different set of locals. Probably fine either way, but worth picking deliberately.

@reardonj

reardonj commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

I'm a little iffy on trying to acquire 2 separate CASes in sequence

They don't have to be acquired together. resume() is just the existing question of who owns the runloop, and the canceler only touches the ack state after resume() has already failed, so it does one CAS. Losing resume() and winning the claim is a well-defined outcome (the fiber is off in blocking, so we schedule fin ourselves), and losing both is fine too, the cancelee will get to it.

Not that they're together, but in general multi-threaded code that is working with multiple locks in any way raises alarm bells in my head that we are entering dangerous waters.


@stasimus , @djspiewak , I worry about the level of complexity we're getting into and more generally the oddness of trying to jam this into the existing API (in particular, needing polls that do nothing in IO but are needed in F is begging for mystifying deadlocks). I'm not convinced that it is correct to combine the concerns of (1) acknowledging cancellation and (2) cancelling blocking operations.

Acknowledgement is needed to safely terminate asynchronous operations (i.e. cancel or complete a fiber before cancelation is effective so we don't lose data). cancelable is an idiom to make a synchronous operation async with cancelation. It just happens to also be broken in its current implementation since it involves fibers, and fibers can't be cleaned up like resources. Making cancelable work without a separate fiber for the blocking operation requires somehow starting the cancelation process outside the runloop, which as we are seeing above, is fraught with concurrency and semantics issues. I find it hard to justify this added complexity.

We are really trying to force in a new behavior [ ie. (1) ] into the typeclass hierarchy that properly should be a binary breaking change.

re: @djspiewak 's comment from @armanbilge 's attempt at this, maybe it's time to talk about CE4 if we really want to do this right. Realistically, any other F: Concurrent that wants to behave safely has to update code to work with this design (ie. change code to use cancelable or whatever we call [1]), which amounts to a breaking change whether MiMa complains or not.

@stasimus

Copy link
Copy Markdown
Contributor

My company just migrated (I hope all repos) CE->CE3 in 2025, CE4 please wait)

"Multiple locks" isn't the shape of the CAS way/proposal. Nothing waits while holding anything, each participant does at most one CAS on a monotonic per-node word, so no deadlock cycle. Deadlock needs someone holding one lock while waiting on another. A CAS isn't held, so that can't happen here? Complexity (maintenance) yes could be a huge.

===========

IMHO The Poll[F] bothers more than the CAS stuff. It's inert in IO but load-bearing in other Fs, and unlike the coordination question it lands in the API. You said in July you weren't convinced joinOrCancel pulls its weight. Lets just cut it, are those 3 reports from header need it?

@reardonj

Copy link
Copy Markdown
Contributor Author

My company just migrated (I hope all repos) CE->CE3 in 2025, CE4 please wait)

A breaking change to add this little bit would be much closer to the 3.5.0 breaks than all the semantic changes of CE2 -> CE3. That release also required downstreams to update, which smells awfully like a major release 😜

IMHO The Poll[F] bothers more than the CAS stuff. It's inert in IO but load-bearing in other Fs, and unlike the coordination question it lands in the API.

💯

You said in July you weren't convinced joinOrCancel pulls its weight. Lets just cut it, are those 3 reports from header need it?

I don't follow, cutting joinOrCancel doesn't fix the poll issue. It's a simple helper once the rest of it is in place. My issue with it was just that it isn't sufficient to solve the problem. We still have extra polls without it.

@stasimus

Copy link
Copy Markdown
Contributor

Opps, I conflated two things. Cutting joinOrCancel doesn't touch the polls, they come from onCancelRequested itself.

Though that does narrow the version question. The polls only exist to make the default implementation work: with fa.onCancel(ack) the ack fires only if cancelation actually gets observed, so call sites have to unmask. IO doesn't need it, because its override starts acks under the mask. Make onCancelRequested abstract with IO's semantics and no call site needs a poll, and there's one behavior instead of two.

So it's either keep the default, and pay for it with polls that are inert in IO plus a guarantee that depends on which instance you picked, or drop the default and break bincompat? In this way I changed opinion towards your position...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants