-
Notifications
You must be signed in to change notification settings - Fork 535
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
cancelable
may leak resources on unsuccessful cancelation
#3474
Comments
1.I realize there is a bigger picture here, but I tried to make your first example (with the def myResource(server: ServerSocketChannel): Resource[IO, SocketChannel] = {
Resource.makeFull[IO, SocketChannel] { poll =>
IO.ref[SocketChannel](null).flatMap { ref =>
poll(
IO.blocking { server.accept() }.guaranteeCase{
case Succeeded(sock) => sock.flatMap(ref.set)
case _ => IO.unit
}.cancelable(IO { server.close() })
).guaranteeCase {
case Succeeded(_) =>
IO.unit
case _ =>
ref.get.flatMap {
case null => IO.unit
case sock => IO { sock.close() }
}
}
}
} { sock =>
IO { sock.close() }
}
} 2.I think the test in 84e7ef1 (with the However, I don't think you actually need this. What you actually need, is the "if a resource was allocated, it will be closed" guarantee. And i think the socket example shows, that there might be a way to guarantee this without needing the behavior in 84e7ef1. |
Thanks for reading :)
Ah, well, this is sort of the crux :) You are right, to restore safety in any specific situation, we don't need any of this. You demonstrated how we can workaround it, and my bespoke However, this is fundamentally a problem of composition. As they are currently working, APIs like
Hm, I don't think it's contradictory. In these situations, cancelation would still be effective, we just need additional suppression. Maybe one way to think about it is, if cancelation was attempted, but the effect did not or could not cancel, then that block must be treated as if it was |
I think I'm starting to understand the problem. But I have more questions :-)
def cancelable(cancel: IO[Unit], release: A => IO[Unit]): IO[A] |
Hm, let me try to summarize it like this.
I don't want to prescribe either behavior :) e.g. consider this snippet: val cancelableAccept: IO[Socket] = ...
IO.uncancelable { poll =>
poll(cancelableAccept).flatMap { socket =>
val doUncancelableStuff = ...
doUncancelableStuff.guarantee(IO(socket.close())
}
} If a user writes code like that, then if the socket is accepted, since the rest of the block is uncancelable, they should be able to do whatever they wanted with it. Although, I agree that in practice, most likely the socket will be closed immediately in this situation.
Yes :) essentially, that
Right, this is possible way to restore safety, similar to my And yes, I do think it makes things pretty specific. Now, any action that must happen if the resource is acquired has to be duplicated in two places: the cancelation handler and the subsequently sequenced effects. This snowballs into a composition problem, because it becomes difficult to sequence uncancelable effects that must be run if the resource is acquired, without passing that all the way down to the original acquiring effect.
Not entirely sure that I agree :) Instead I would say: if you have a cancelable acquire, you must be very careful to mask cancelation as soon as you have acquired the resource, so that it is guaranteed that it will be passed along to any subsequently sequenced effects. Basically, I don't think that cancelable effects should get into the business of knowing how to cleanup the value that they are returning. They should absolutely cleanup any resources created during the acquisition process (e.g. registered callbacks) since those are internal details. If we disagree, and say that it is reasonable for a cancelable effect to know how to cleanup the value it is returning, then I would say that this cancelable effect should actually be a def cancelable(cancel: IO[Unit], release: A => IO[Unit]): Resource[IO, A] So then, we are basically introducing a bunch of additional Footnotes
|
So ultimately this distills down to the behavior of In a sense, this is just fundamental to how fibers work. You have to be careful to not leak results if you care about them.
|
I agree that this is fundamental, but I lean more towards fundamental hole in our model 😅 do you propose to patch If I may, here's a naïve sketch of how we might fix it. The rough idea is that:
The goal of all this is to reuse the existing mechanism for uncancelability, which ensures that the final outcome will be passed along, regardless of whether cancelation was requested or not. Note that under this scheme, From a backwards-compatibility standpoint, this change would be a bit on shaky footing. I think a reasonable default implementation could be for |
That proposal won't fix it though, because once a fiber is canceled, you can't get access to results anymore. Note in my example from previously. When the inner fiber is canceled, it can only access the results of the inner inner fiber by directly joining that fiber. We can't sequence the results through flatMap anymore… because we were canceled! |
Hm, not sure I understand :) IO.uncancelable { _ =>
gate.get *> IO.pure(42)
} And the finalizer is To be clear, are you saying that the Edit: to be very very clear: I'm proposing that |
Here you go :) |
I don't think that's entirely fair to say. It's like saying that
Yes, this is a very clear explanation, and this seems to be the essence of the issue (as I understand it). (And note, that this also does not guarantee, that there will be no leak: it just makes it possible for the user to handle it safely; but if they just I've also looked at #3491, and it's not clear to me, how it would help with |
💯 yes, very well put. The key thing is to make it possible to be handled safely e.g. by putting it inside of a
It doesn't need to be further generalized :) it's exactly what we need to fix |
Coming back to this… As a baseline, I'm very leery adding something like So that leaves us with a couple options:
The final possible option is simply this: we could ignore this whole mess. As @durban pointed out, using Resource.makeFull { poll =>
IO.deferred[Outcome[IO, Throwable, Socket]] flatMap { d =>
val polled = poll {
IO.blocking(server.accept()).guaranteeCase(d.complete(_).void).cancelable(server.shutdown())
}
polled onCancel {
d.get flatMap {
case Outcome.Completed(ios) => ios.flatMap(s => IO(s.close()))
case _ => IO.unit
}
}
}
}(socket => IO(socket.close())) Put another way, this would be accepting (and obviously documenting) that |
Yep, I totally get this :) but here's the thing: my proposal is no longer just about
|
Spinning this out of a Discord observation so that it doesn't get lost. It relates to the new
cancelable
combinator added in #3460.This issue is important, because it has led me to believe we cannot currently implement the non-leaking
race
proposed in #3456.For motivation, consider this case:
There, we have a blocking call to
accept
a socket on a server. We can cancel it by shutting down the server. However, if the blocking call succeeds (perhaps in a race condition withshutdown()
), and we do get an open socket, we really want to be sure that we close it.To demonstrate the leak I have added a test in 84e7ef1.
cats-effect/tests/shared/src/test/scala/cats/effect/IOSpec.scala
Lines 1094 to 1100 in 84e7ef1
In this case, "cancelation" is simply causing the
Deferred
to complete normally. So that means theget
is completing with()
. If that's a resource, we don't want to lose that, so the outcome should be successful, not canceled.It's a bit weird, but conceptually this is equivalent to a race condition where you request cancellation, but the effect in question succeeds anyway. In that case, it didn't really cancel, and if you pretend like it did, you can leak a resource.
Now, what worries me is that I don't see how we can fix this. And if we can't fix this, I don't see how we can fix #3456.
cats-effect/kernel/shared/src/main/scala/cats/effect/kernel/GenSpawn.scala
Lines 264 to 269 in 7a5311b
So, the problem is that if cancelation occurs while this fiber is in
poll(fiber.join)
, then it is assumed that it canceled! That means thatcancelable
can no longer have a successful outcome.The problem is that until
fiber
completes postfiber.cancel
, and wejoin
it and find out how it completed, then we can't really say for sure whether it actually canceled or not. And if it didn't cancel, and it actually returned a successful result, we have no way to get this out ofcancelable
anymore. So, leak.What we are currently lacking is a way to request cancelation, but be prepared for the fact that it might not happen. This is a common pattern: Java's
Future#cancel
returns aboolean
indicating precisely this information. So does io_uring's cancelation mechanism.In fact, I ran into this same problem when working on fs2-io_uring, and was forced to add a bespoke API to safely lift an async syscall into a resource. For example, a syscall opening/accepting a socket, file, or any other resource.
Not surprisingly, our
Async#fromCompletableFuture
suffers the exact same problem.cats-effect/kernel/jvm/src/main/scala/cats/effect/kernel/AsyncPlatform.scala
Lines 52 to 56 in a9eac6c
cf.cancel()
may returnfalse
, indicating that cancelation was not possible. So then we fallback to waiting for its completion viaget
... but what if this is aCompletableFuture
that is asynchronously waiting for an accepted socket? Leak. So it would not be safe to use this API in aResource.makeFull(...)(...)
.Sorry for wall of problems, no solutions 😕
The text was updated successfully, but these errors were encountered: