parsonsmatt / annotated-exception

Machinery for throwing and catching exceptions with some annotation.
BSD 3-Clause "New" or "Revised" License
32 stars 3 forks source link

Annotate asynchronous exceptions #20

Open parsonsmatt opened 1 year ago

parsonsmatt commented 1 year ago

Right now, none of the functions in this codebase do anything with asynchronous exceptions. This means you don't get any annotations! This sucks, and we should fix it.

However, we need to fix it such that async exceptions are annotated, and that the "asynchronicity" of that exception is not altered. For example, doing a checkpoint must not change an exception from async ot sync such that a later catch can handle it.

parsonsmatt commented 1 year ago

Well, this is harder than I thought it would be. There are multiple ways to throw async exceptions!

Control.Exception.throwTo

This calls toException :: Exception e => e -> SomeException before delivering it to the target thread, so the target thread has a pretty simple SomeException that blasts it down.

UnliftIO.Exception.throwTo

This calls toAsyncException, which does a bit of a dance:

toAsyncException :: Exception e => e -> SomeException
toAsyncException e =
    case fromException se of
        Just (SomeAsyncException _) -> se
        Nothing -> toException (AsyncExceptionWrapper e)
  where
    se = toException e

-- | Wrap up a synchronous exception to be treated as an asynchronous
-- exception.
--
-- This is intended to be created via 'toAsyncException'.
--
-- @since 0.1.0.0
data AsyncExceptionWrapper = forall e. Exception e => AsyncExceptionWrapper e
    deriving Typeable
-- | @since 0.1.0.0
instance Show AsyncExceptionWrapper where
    show (AsyncExceptionWrapper e) = show e
-- | @since 0.1.0.0
instance Exception AsyncExceptionWrapper where
    toException = toException . SomeAsyncException
    fromException se = do
        SomeAsyncException e <- fromException se
        cast e
#if MIN_VERSION_base(4,8,0)
    displayException (AsyncExceptionWrapper e) = displayException e
#endif

So, an UnliftIO.Exception.throwTo tid Foo will check to see if Foo is an async exception. Foo would "opt in" to being an async exception by using toAsyncException in the Exception instance, instead of the default. This would look like the AsyncException instance, which uses toException = asyncExceptionToException = toException . SomeAsyncException.

If the Exception is not natively async, then it gets wrapped in UnliftIO.Exception.AsyncWrapper.

If Foo is normal, then the exception delivered to the thread is a SomeException (SomeAsyncException (UnliftIO.AsyncExceptionWrapper Foo)).

Control.Exception.Safe.throwTo

This one is just like UnliftIO, but it uses a different AsyncExceptionWrapper type. So you need to handle these cases separately.

Unifying Approaches

It'd be really nice if unliftio and safe-exceptions used the same type. But currently neither depend on each other. Even when this is done, library authors will need to support both AsyncExceptionWrappers.

However, until then, it seems reasonable that annotated-exception will need to be able to "see through" both kinds of AsyncExceptionWrapper and handle things nicely.