-
Notifications
You must be signed in to change notification settings - Fork 50
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
Fix cancel
in exception handling code
#96
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,7 +3,7 @@ | |
module UnliftIO.ExceptionSpec (spec) where | ||
|
||
import qualified Control.Exception | ||
import Control.Monad (void, (<=<)) | ||
import Control.Monad (void, (<=<), when) | ||
import Data.Bifunctor (first) | ||
import Test.Hspec | ||
import UnliftIO | ||
|
@@ -79,6 +79,27 @@ spec = do | |
result <- withWrappedAsyncExceptionThrown $ \m -> trySyncOrAsync (void m) | ||
first fromExceptionUnwrap result `shouldBe` Left (Just Exception1) | ||
|
||
describe "withException" $ do | ||
it "should work when withAsync is in the handler" $ do | ||
let | ||
action = | ||
error "oops" | ||
`onException` do | ||
let | ||
timerAction n = do | ||
threadDelay 1000000 | ||
when (n < 10) $ do | ||
timerAction (n + 1) | ||
withAsync (timerAction 0) $ \a -> do | ||
cancel a | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line can be replaced with |
||
eresult <- | ||
race | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
(action `shouldThrow` errorCall "oops") | ||
(do | ||
threadDelay 1000000 | ||
pure 10) | ||
eresult `shouldBe` Left () | ||
|
||
describe "fromExceptionUnwrap" $ do | ||
it "should be the inverse of toAsyncException" $ do | ||
fromExceptionUnwrap (toAsyncException Exception1) `shouldBe` Just Exception1 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an alternative fix to using
restore
in the handler ofwithException
andbracket
.The
Async
API is totally dependent on being able to throw async exceptions to the other thread. I don't thinkAsync
makes any sense if it cannot do that.While this single fix works here, it won't work for eg
race
or any of the other code, so we may want to patch those as well.I'm almost curious if this is a thing that needs to get raised with upstream
Async
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented here simonmar/async#67
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd be very worried about two things here:
async
's handling of these casesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Upstream hasn't responded, so, we may want to handle this here?
I have a hard time imagining any code that depends on
withAsync
creating an unkillable zombie and being unable to exit.This is a decently large footgun that is only really likely to happen if you are using
unliftio
orsafe-exceptions
. While I do think thatasync
should really be launching threads in at most aMaskedInterruptible
state, it's not super likely to be triggered -uninterruptibleMask
is pretty rare to see, and has lots of warning labels.Since
unliftio
uses it a bunch in cleanup functions, though, I think it does make sense to make the combination ofUnliftIO.Exception
andUnliftIO.Async
not quite so dangerous.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not following why this situation is worse with
unliftio
orsafe-exceptions
, it seems like other code would have the same behavior.I'm still very much on the fence here. I think the change is a good one, but the idiosyncrasies it may cause may not be worth it.
One specific question about the implementation here: IIUC, this is changing uninterruptible masking to unmasked, but leaving interruptible masking as it is. That seems surprising. Should uninterruptible become interruptible, or both become unmasked?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unlikely because most people read the docs for
uninterruptibleMask
and decide they probably don't need it. So the main way it becomes a part of people's programs is whensafe-exceptions
orunliftio
style cleanups enter the picture and puts it in withbracket
and friends.But, yeah, anyone that runs
uninterruptibleMask_ $ async $ do ...
is going to create an unkillableAsync
.I'm coming around that it should probably be
unmask
ed in theasync
andwithAsync
functions, and definitely unmasked inrace
andrace
-like functions. If the intent is "one of these threads should die," then unmasking should happen. Butconcurrently
poses a slightly different question: Simon Marlow points out thatfinally foo (a >> b)
andfinally foo (concurrently a b
should have the same semantics, but ifconcurrently
unmasks, then the action is no longer protected. So the appropriate default forconcurrently
may be different than forrace
, since the expectations/assumptions are different.Likewise,
withAsync
seems to demand the unmasking much more -uninterruptibleMask_ $ withAsync foo bar
cannot terminate iffoo
is aforever
loop - you're deadlocked. ButuninterruptibleMask_ $ async foo
is less likely to deadlock, since it's entirely possible that you nevercancel
it, and thus never have the problem.I'm not really sure the best way to approach it. Maybe separate modules with different behavior is the right answer? A separate opt-in package?
My chaos brain says something like
UnliftIO.Async
needs to export these functions with aMaskingBehavior
argument, forcing all users to upgrade, and maybe give a warning/deprecation message that points to alternative modules. But that's a big breaking change for probably little benefit for those that aren't already experiencing the pain.Yeah, that's a good pont - unfortunately, we're missing a primop.
I have opened an upstream issue: ideally, we'd have a primop
unsafeUnUninterruptibleMask#
that would downgrade aUninterruptibleMask
to aInterruptibleMask
. But we don't have that, so the alternative ofasyncWithUnmask $ \unmask -> unmask $ mask_ action
actually is vulnerable to an async exception at theunmask
point, sinceunsafeUnmask
(the function provided asunmask
) triggers any queued async exceptions in the C--. In the case of async, this is really unlikely - I think you'd need tocancel =<< async action
to observe the async exception slipping between theunmask $ mask_
.Switching from
mask_
(which checks masking state; should be unnecessary afterunmask
) toblock
would also accomplish this.