Exception annotations were introduced in GHC 9.10, and can be an invaluable tool for debugging thorny problems. The initial implementation had some important limitations that made them less useful in practice than one might hope, but fortunately the situation has since been much improved. In this blog post we will give a detailed overview of the status quo as of GHC 9.12/9.14, identify some gotchas you should be aware and provide advise on how to deal with them, and briefly look ahead to what will change in GHC 10.0. We will also dedicate a section to discussing the problems in GHC 9.10, for those who cannot yet upgrade.
Although we will recap all necessary definitions, this blog is not meant to be an introduction to exception annotations; if you have never used them before, you might first want to watch The Haskell Unfolder Episode 29: exceptions, annotations and backtraces.
Backtraces
Before we look at the general framework for exception annotations, let’s first briefly recap the concept of backtraces, which is GHC’s answer to stack traces in other languages. The situation is more complicated in Haskell due laziness, and there are actually four different kinds of backtraces:
- based on
HasCallStackannotations - based on cost-centres (which will require compiling your program with profiling enabled)
- based on IPE info
- based on DWARF info
In this blog post we will use the first two only, but for the purposes of our main discussion here the choice actually does not matter much; see GHC proposal Decorate exceptions with backtrace information for details. If you’re interested in IPE backtraces specifically, you might also be interested in our blog post Better Haskell stack traces via user annotations, which discusses some recent extensions we implemented to improve these.
HasCallStack backtraces
Consider this simple Haskell program, where main calls top calls middle
calls bottom:
bottom :: HasCallStack => IO ()
bottom = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
middle :: HasCallStack => IO ()
middle = bottom
top :: HasCallStack => IO ()
top = middle
main :: IO ()
main = topA HasCallStack is essentially an additional function argument which is
automatically populated by GHC at call sites with information about where the
function was called. When we run this program, we see something like this:
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCallStack.hs:13:11 in (..)
bottom, called at exe/DemoCallStack.hs:18:10 in (..)
middle, called at exe/DemoCallStack.hs:22:7 in (..)
top, called at exe/DemoCallStack.hs:25:8 in (..)
The only thing worth noting here is that the moment a HasCallStack chain is
broken, the backtrace is cut off there. For example, if middle does not have a
HasCallStack constraint, we can no longer see where middle was called from:
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCallStack.hs:19:11 in (..)
bottom, called at exe/DemoCallStack.hs:24:10 in (..)
The fact that top still has a HasCallStack constraint does not matter: the
callstack is cut at the first missing link.
Cost centre backtraces
Cost centres are how GHC implements profiling: very roughly, the cost of a
computation is attributed to its enclosing cost centre (see chapter
Profiling of the GHC manual). Like HasCallStack,
this relies on source code annotations:
{-# SCC bottom #-}
bottom :: HasCallStack => IO ()
bottom = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
{-# SCC middle #-}
middle :: IO ()
middle = bottom
{-# SCC top #-}
top :: HasCallStack => IO ()
top = middle
{-# SCC main #-}
main :: IO ()
main = do
setBacktraceMechanismState CostCentreBacktrace True
topUnlike HasCallStack, however, GHC offers ways for inserting such annotations
automatically, which can often make cost centre based callstacks more practical
than HasCallStack. The most common flag to do this is -fprof-auto or (in
recent GHC) -fprof-late (see Late Cost Centre
Profiling). This inserts cost centres around all
top-level functions, as we did manually above.
Cost centre backtraces must be explicitly enabled by calling
setBacktraceMechanismState, and you need
to compile your code with profiling enabled; the
cabal option --enable-profiling
both enables profiling as well as automatic cost centre insertion. The backtrace
for this example might look something like
Cost-centre stack backtrace:
DemoCallStack.main (exe/DemoCallStack.hs:(32,1)-(34,7))
DemoCallStack.top (exe/DemoCallStack.hs:28:1-12)
DemoCallStack.middle (exe/DemoCallStack.hs:24:1-15)
DemoCallStack.bottom (exe/DemoCallStack.hs:(18,1)-(20,35))
Be aware however that optimizations can delete cost centres, especially in simple examples like this (#27225).
Cost centres vs exception handling
Consider the following example: as before, main calls top calls middle
calls bottom, which prints a backtrace; however bottom then throws an
excepton. Meanwhile, main installs an exception handler called handlerTop,
which in turn calls handlerMiddle calls handlerBottom, which prints its
own backtrace:
bottom :: HasCallStack => IO ()
bottom = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
throwIO $ userError "Uhoh"
middle :: HasCallStack => IO ()
middle = bottom
top :: HasCallStack => IO ()
top = middle
handlerBottom :: HasCallStack => SomeException -> IO ()
handlerBottom _e = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
handlerMiddle :: HasCallStack => SomeException -> IO ()
handlerMiddle e = handlerBottom e
handlerTop :: HasCallStack => SomeException -> IO ()
handlerTop e = handlerMiddle e
main :: IO ()
main = do
setBacktraceMechanismState CostCentreBacktrace True
top `catch` handlerTopThe HasCallStack backtrace printed by bottom is
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCCS.hs:24:11 in (..)
bottom, called at exe/DemoCCS.hs:29:10 in (..)
middle, called at exe/DemoCCS.hs:32:7 in (..)
top, called at exe/DemoCCS.hs:41:5 in (..)
as before; the HasCallStack printed by handlerBottom is very similar:
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCCS.hs:13:11 in (..)
handlerBottom, called at exe/DemoCCS.hs:17:19 in (..)
handlerMiddle, called at exe/DemoCCS.hs:20:16 in (..)
handlerTop, called at exe/DemoCCS.hs:41:18 in (..)
For the cost-centre based backtrace, the one shown in bottom is as before:
Cost-centre stack backtrace:
DemoCCS.main (exe/DemoCCS.hs:(39,1)-(41,27))
DemoCCS.top (exe/DemoCCS.hs:32:1-12)
DemoCCS.middle (exe/DemoCCS.hs:29:1-15)
DemoCCS.bottom (exe/DemoCCS.hs:(23,1)-(26,30))
but the one shown in handlerBottom is more surprising:
Cost-centre stack backtrace:
DemoCCS.main (exe/DemoCCS.hs:(39,1)-(41,27))
DemoCCS.top (exe/DemoCCS.hs:32:1-12)
DemoCCS.middle (exe/DemoCCS.hs:29:1-15)
DemoCCS.bottom (exe/DemoCCS.hs:(23,1)-(26,30))
DemoCCS.handlerTop (exe/DemoCCS.hs:20:1-30)
DemoCCS.handlerMiddle (exe/DemoCCS.hs:17:1-33)
DemoCCS.handlerBottom (exe/DemoCCS.hs:(12,1)-(14,35))
Whether or not this is expected/correct behaviour is arguable, but the rule is
this: the cost centre stack is not restored until we leave the scope of
catch. Put another way: the cost centre stack reflects the fact that bottom
“calls” handlerTop, however indirectly. This applies transitively: if
handlerTop would throw an exception, which would then be caught by some other
exception handler, then its backtrace would reflect that top “called”
handlerTop “called” that other exception handler. This kind of situation can
arise quite naturally, for example when using handlers that deallocate some
resources and then rethrow the exception.
Basic definitions
Before we look at the subtleties that arise from actually catching and throwing (or rethrowing) exceptions, we’ll first get the basic definitions out of the way. These have not changed much between recent GHC versions and are hopefully uncontroversial.
Exception annotations
Exceptions annotations can basically be anything at all; the only requirement is that that we can display them:
class Typeable a => ExceptionAnnotation a where
displayExceptionAnnotation :: a -> StringAn important instance of this class is Backtraces, which
wraps a set of different kinds of backtraces:
instance ExceptionAnnotation Backtraces where
displayExceptionAnnotation = Base.displayBacktracesException context
An exception context is essentially just a list of exception annotations. However, since those annotations may be of different types, we need to wrap them in an existential:
data ExceptionContext = ExceptionContext [SomeExceptionAnnotation]
data SomeExceptionAnnotation = forall a. ExceptionAnnotation a => SomeExceptionAnnotation aThere are functions for manipulating the exception context. The most important
are emptyExceptionContext and
addExceptionAnnotation, for creating an
empty context and inserting an annotation into an existing context respectively.
emptyExceptionContext :: ExceptionContext
addExceptionAnnotation :: ExceptionAnnotation a => a -> ExceptionContext -> ExceptionContextPivotal change: SomeException
The pivotal change in all of this is in the definition of SomeException which,
starting in GHC 9.10, now has an associated list of annotations:
data SomeException = forall e. (Exception e, HasExceptionContext) => SomeException e
type HasExceptionContext = (?exceptionContext :: ExceptionContext)The use of an implicit parameter means that pattern
matching on SomeException remains possible in the same way as before (though
the annotations would be silently dropped).
There are various functions for extracting and manipulating the exception
context associated with an exception, such as
someExceptionContext and
addExceptionContext:
someExceptionContext :: SomeException -> ExceptionContext
addExceptionContext :: ExceptionAnnotation a => a -> SomeException -> SomeExceptionHowever, probably the most important function for extending exception contexts
is annotateIO, which installs an exception handler that
extends any exception that is thrown with the specified annotation:
annotateIO :: forall e a. ExceptionAnnotation e => e -> IO a -> IO a
annotateIO ann (IO io) = IO (PrimOp.catch# io handler)
where
handler se = PrimOp.raiseIO# (addExceptionContext ann se)It is important to emphasize that this is implemented with
primops, not with the regular catch and throwIO
functions, which do considerably more than merely catching and throwing, as we
shall see.
Exception type class
The Exception type class is a central abstraction in Haskell’s exception
ecosystem. As part of the exception annotation work, it has received one minor
extension, and it was changed in two not-so-minor-but-rather-subtle ways. Let’s
first get the part out of the way which has not changed: exceptions are no
good if we cannot see them:
class (Typeable e, Show e) => Exception e where
displayException :: e -> String
displayException = show
-- (..)backtraceDesired
The minor extension is a new function called
backtraceDesired, which indicates if a backtrace
should be attached to exceptions of this type; we will see how this function is
used when we discuss the implementation of throwIO.
class (Typeable e, Show e) => Exception e where
-- (..)
backtraceDesired :: e -> Bool
backtraceDesired _ = TrueThe argument to backtraceDesired is already fully constructed exception; the
question is whether a backtrace should be added to that exception. In most
cases the argument can simply be ignored, but it doesn’t have to be. For all
but a handful of specialized cases the default implementation (indicating that
yes, we want a backtrace) will be fine.
fromException
The not-so-minor-but-rather-subtle changes are in fromException and
toException, which remove and add the SomeException wrapper around
exceptions respectively. Let’s first look at fromException:
class (Typeable e, Show e) => Exception e where
-- (..)
fromException :: SomeException -> Maybe e
fromException (SomeException e) = cast eThis may look no different from the implementation
prior to 9.10,
but recall that SomeException now has an additional field: the exception
annotations. As mentioned above, a pattern match like this will silently discard
those annotations.
toException
The final function in the Exception class is toException, which is intended
to add the SomeException wrapper.
class (Typeable e, Show e) => Exception e where
-- (..)
toException :: e -> SomeExceptionPrior to 9.10, the default implementation literally just added the
SomeException constructor:
-- implementation prior to 9.10
toException = SomeExceptionHowever, starting in 9.10 we also need to give an initial value for the exception context. The default implementation, reasonably enough, chooses the empty context:
-- implementation in 9.10, 9.12, 9.14, and 10.0
toException e = let ?exceptionContext = emptyExceptionContext in SomeException eUnfortunately, however, the documentation of toException has also been
modified, and now states that toException should produce a SomeException
with no attached ExceptionContext. Personally, I think
that is a mistake (#27194); we will discuss this in
the next session.
⚠️ Caution: Instance for SomeException itself
SomeException itself is also an instance of Exception; fromException is
trivial, and backtraceDesired and displayException piggy-back on the
definition of whatever exception is wrapped:
instance Exception SomeException where
fromException = Just
backtraceDesired (SomeException e) = backtraceDesired e
displayException (SomeException e) = displayException e
-- (..)The definition of toException is more problematic, however. Prior to 9.10,
calling toException on SomeException was just an identity:
instance Exception SomeException where
-- (..)
-- Prior to 9.10
toException se = seNow, however, the implementation must clear the existing context in order to satisfy the contract:
instance Exception SomeException where
-- (..)
toException (SomeException e) = let ?exceptionContext = emptyExceptionContext in SomeException eI think this is simply wrong; at the very least, it is highly counter-intuitive, and it also does not match the original proposal; I don’t know why this was changed. We will see some consequences of this design choice when we discuss throwing exceptions.
Newtype helpers
There are two auxiliary types, with their own Exception instances, that can be
helpful when throwing or catching exceptions in specific ways. We haven’t
discussed either throwing or catching yet, but we will nonetheless discuss these
auxiliary types first as we will need them in the subsequent sessions.
NoBacktrace
NoBacktrace can be used to override backtraceDesired:
newtype NoBacktrace e = NoBacktrace e
instance Exception e => Exception (NoBacktrace e) where
fromException = fmap NoBacktrace . fromException
toException (NoBacktrace e) = toException e
backtraceDesired _ = False
-- displayException left at its default implementationExceptionWithContext
The other, arguably more imporant, auxiliary type is ExceptionWithContext.
The definition itself is straight-forward: it simply pairs some value with an
exception context:
data ExceptionWithContext a = ExceptionWithContext ExceptionContext aThe idea is that this type gives us a way to catch exceptions of specific types (rather than catching SomeException), and still get access to the exception context. For example:
data MyException = MyException
deriving stock (Show)
deriving anyclass (Exception)
example :: IO ()
example = someAction `catch` \(ExceptionWithContext ctxt MyException) -> do
-- (..)The implementation is reasonably straight-forward:
instance Exception a => Exception (ExceptionWithContext a) where
toException (ExceptionWithContext ctxt e) =
case toException e of
SomeException c ->
let ?exceptionContext = ctxt
in SomeException c
fromException se = do
e <- fromException se
return (ExceptionWithContext (someExceptionContext se) e)
backtraceDesired (ExceptionWithContext _ e) = backtraceDesired e
displayException = displayException . toExceptionThat said, the devil is very much in the detail with these kinds of definitions, and as we shall see, it was defined incorrectly in GHC 9.10.
Throw
The primary function for throwing an exception is throwIO, which is defined
as1
throwIO :: (HasCallStack, Exception e) => e -> IO a
throwIO e = do
se <- toExceptionWithBacktrace e
IO (PrimOp.raiseIO# se)Most of the actual work happens in toExceptionWithBacktrace:
toExceptionWithBacktrace :: (HasCallStack, Exception e) => e -> IO SomeException
toExceptionWithBacktrace e =
if backtraceDesired e then do
bt <- Base.collectBacktraces
return (addExceptionContext bt (toException e))
else
return (toException e)That is, if a backtrace is desired, we collect one and add it as an annotation to the exception that we’re about to throw.
Generalization
In GHC 9.14 toExceptionWithBacktrace was generalized to
toExceptionWithBacktrace :: (HasCallStack, Exception e) => e -> IO SomeException
toExceptionWithBacktrace e =
if backtraceDesired e then do
SomeExceptionAnnotation ea <- collectExceptionAnnotation
return (addExceptionContext ea (toException e))
else
return (toException e)This is an experimental API (not yet part of base); see CLC #348
for details. The idea is that you can use
setCollectExceptionAnnotation to
register your own function to be run to construct an annotation whenever an
exception is thrown anywhere. For example, if you’re worried that some IO faults
are happening due to your CPU overheating, you might use
newtype Temperature = Temperature Int
deriving stock (Show)
deriving anyclass (ExceptionAnnotation)
getTempCPU :: IO Temperature
getTempCPU = -- (..)
main :: IO ()
main = do
setCollectExceptionAnnotation getTempCPU
-- (..)By default, the collection callback is collectBacktraces, so unless you
register a different callback the behaviour is the same as in 9.10 and 9.12.
⚠️ Caution: Throwing SomeException
Because throwIO calls toException, and since toException for
SomeException
clears the exception context,
you probably don’t want to call throwIO on an argument of type
SomeException: any exception annotations that might be embedded in that
exception will be lost.
The most common case for throwing SomeException is inside an exception
handler; we will cover this specific case of rethrowing exceptions when we
discuss onException, but we can
reuse the same combinators also to define a general “throw precisely this
exception” function:
raiseIO :: SomeException -> IO ()
raiseIO (SomeException e) = rethrowIO (ExceptionWithContext ?exceptionContext e)Catch
The most important change in GHC 9.12 from 9.10 is in the definition of catch, which now implements the WhileHandling proposal. The idea is that
when we throw a new exception while handling another, we annotate that new
exception with the old exception: the new exception arose while handling
the old exception:
data WhileHandling = WhileHandling SomeException deriving Show
catch :: Exception e => IO a -> (e -> IO a) -> IO a
catch (IO io) handler = IO $ PrimOp.catch# io handler'
where
handler' se =
case fromException se of
Just e' -> PrimOp.catch# (unIO (handler e')) (handler'' se)
Nothing -> PrimOp.raiseIO# se
handler'' se se' = PrimOp.raiseIO# (addExceptionContext (WhileHandling se) se')⚠️ Caution: Rethrowing the same exception
An important combinator for dealing with exceptions is onException, which
runs some specified action when an exception occurs (typically some resource
cleanup) and then rethrows the exception again:
onException :: IO a -> IO b -> IO a
onException io what = io `catch` \e -> do
_ <- what
throwIO (e :: SomeException)As written, this is suboptimal: for every layer of onException, we re-throw
the annotation stripped from its original annotations (due to throwIO and
toException for SomeException), and with a new WhileHandling annotation
with the original exception (due to catch). This result in unnecessary noise:
all the information is still there, but it’s buried. When we rethrow the same
exception, there is no need for WhileHanding: we should just throw the
original exception as-is.
To solve this, base now offers new functions specifically to
catch-and-rethrow: catchNoPropagate2 is like the old catch, without
the handler that adds the WhileHandling annotation; and rethrowIO, which
avoids adding a backtrace (using NoBacktrace; moreover, both
of these explicitly preserve contexts (using
ExceptionWithContext):
catchNoPropagate :: Exception e => IO a -> (ExceptionWithContext e -> IO a) -> IO a
catchNoPropagate (IO io) handler = IO $ PrimOp.catch# io handler'
where
handler' se =
case fromException se of
Just e' -> unIO (handler e')
Nothing -> PrimOp.raiseIO# se
rethrowIO :: Exception e => ExceptionWithContext e -> IO a
rethrowIO e = throwIO (NoBacktrace e)This then enables the following improved implementation of onException:
onException :: IO a -> IO b -> IO a
onException io what = io `catchNoPropagate` \e -> do
_ <- what
rethrowIO (e :: ExceptionWithContext SomeException)⚠️ Caution: Displaying exceptions
The final pitfall we need to discuss is displaying exceptions. Usually we call
displayException to do so, but this does not show annotations. The idea is
that displayException is meant to render an exception for users, not
necessarily developers.3 Starting withGHC 9.14 there is a separate function
displayExceptionWithInfo, but that is not
available in GHC 9.12; moreover, even in GHC 9.14 I would advise against using
it when you are debugging, as it only shows the top-level annotations, making
things like WhileHandling much less useful.
Personally, I like to use my own custom exception handler which shows the full exception, and makes a few other improvements also: it makes the nesting structure clearer, and reorders annotations to improve readability; you can find an example implementation on GitHub .
GHC 9.10
If you cannot upgrade from GHC 9.10, unfortunately the exception annotation infrastructure has some important limitations. Upgrade if you can; if not, this section will explain what you need to be aware of.
Lost annotations
As we remarked when we discussed catch, the WhileHandling proposal
only got implemented in GHC 9.12. In GHC 9.10 the definition of catch was still
unchanged from its definition before the exception annotation proposal:
catch :: Exception e => IO a -> (e -> IO a) -> IO a
catch (IO io) handler = IO $ PrimOp.catch# io handler'
where
handler' se =
case fromException se of
Just e' -> unIO (handler e')
Nothing -> PrimOp.raiseIO# seHowever, the
Exception instance for SomeException
was already changed, so that toException clears the exception context.
This means that if an exception with annotations is ever caught and rethrown
anywhere, in a pattern such as
someAction `catch` \(e :: SomeException) -> throwIO ethose annotations will be lost. Similarly, since onException had not yet been
changed either, any call to onException, and by implificationbracket,
anywhere in your callstack would also lose any annotations:
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket before after thing =
mask $ \restore -> do
a <- before
r <- restore (thing a) `onException` after a
_ <- after a
return r
onException :: IO a -> IO b -> IO a
onException io what = io `catch` \e -> do
_ <- what
throwIO (e :: SomeException)In both cases, throwIO will insert a new backtrace, but that backtrace will
point to where the exception was rethrown, not to where it was thrown
originally. What’s worse, neither bracket nor onException have a
HasCallStack constraint, so all we see in the callstack is the call to
throwIO from onException itself.
Cost centre stacks do help a bit here (provided you enable profiling): at least you’ll get to see the full backtrace to the exception handler, and with a bit of luck even to the original call to throw, due to the semantics of semantics of cost centres in exception handlers. That won’t always be the case though (for example, in the case of asynchronous exceptions), and you won’t see any of the additional annotations that might have been added to the exception.
Duplicated annotations
The Exception instance for ExceptionWithContext in GHC 9.10 has an
incorrect definition for toException:
instance Exception a => Exception (ExceptionWithContext a) where
-- (..)
-- implementation in GHC 9.10
toException (ExceptionWithContext ctxt e) =
let ?exceptionContext = ctxt in SomeException e(We saw the correct definition above.)
This is wrong for two reasons:
- It does not use
toExceptionof the underlying type (theatype parameter); in most cases this does not matter, becausetoExceptionrarely does anything interesting. Even in the case ofSomeException, wheretoExceptiondoes something “interesting” (if perhaps ill-advised), to wit clear the exception context, that doesn’t matter here because we are overriding that context anyway. However, there might be types wheretoExceptiongenuinely does something important (even if I am not aware of any such cases currently). - In the specific case that
aisSomeException, this will create a nestedSomeException:SomeException (SomeException someOtherException)with two copies of the context (the annotations).
The second point here is more important: if we later have exception handlers that manipulate the exception context, they will manipulate the outer context but not the inner. Indeed, if that “manipulation” is “clear the context” (see previous section), we might end up in the somewhat bizarre situation where these two problems cancel out: if we have
someAction `catch` \(ExceptionWithContext ctxt (e :: SomeException))
throwIO $ ExceptionWithContext ctxt ethen this exception handler will duplicate the annotations, a later exception handler might lose the outermost annotations (previous section) but not the inner, and all of a sudden annotations that were lost mysteriously re-appear; see GHC ticket #27194.
Unfortunately, this is not a viable workaround for the lost annotation problem,
as it changes the type of the exception nested in the (outer) SomeException
from whatever it really should have been to (the inner) SomeException, which
will break any exception handlers for that specific type.
GHC 10.0
The upcoming GHC 10.0 releases makes a few improvements to the exception annotation infrastructure. The first important improvement is that exception handling in STM was lagging behind a bit; this will be rectified (#25365).
The other important fix is in onException. Consider again the definition
we saw when we discussed rethrowing exceptions:
onException :: IO a -> IO b -> IO a
onException io what = io `catchNoPropagate` \e -> do
_ <- what
rethrowIO (e :: ExceptionWithContext SomeException)We mentioned that that catchNoPropagate does not install an exception
handler that installs a WhileHandling annotation, because we are rethrowing
the very same exception. However, if what throws an exception that is no
longer the case! The definition of onException is therefore modified to
onException io what = io `catchNoPropagate` \e -> do
_ <- annotateIO (whileHandling e) what
rethrowIO (e :: ExceptionWithContext SomeException)See CLC Proposal #397 for details. As an example, consider what happens if the release callback
of bracket itself throws an exception:
data ReleaseFailed = ReleaseFailed
deriving stock (Show)
deriving anyclass (Exception)
bottom :: HasCallStack => IO ()
bottom = annotateIO (MyAnnotation 123456789) $ throwIO MyException
middle :: HasCallStack => IO ()
middle = bracket (return ()) (\() -> throwIO ReleaseFailed) $ \() -> bottom
top :: HasCallStack => IO ()
top = middleWith the new definition onException (and my custom exception display function, which is still needed), we get
demo-bracket-release-fail: Uncaught exception of type ReleaseFailed
ReleaseFailed
HasCallStack backtrace:
throwIO, called at exe/DemoBracketReleaseFail.hs:42:38 in (..)
middle, called at exe/DemoBracketReleaseFail.hs:46:7 in (..)
top, called at exe/DemoBracketReleaseFail.hs:55:5 in (..)
WhileHandling
MyException
MyException
MyAnnotation 123456789
HasCallStack backtrace:
throwIO, called at exe/DemoBracketReleaseFail.hs:38:48 in (..)
bottom, called at exe/DemoBracketReleaseFail.hs:42:70 in (..)
middle, called at exe/DemoBracketReleaseFail.hs:46:7 in (..)
top, called at exe/DemoBracketReleaseFail.hs:55:5 in (..)
Very nice!
Conclusions
Exception annotations can be invaluable when debugging difficult problems. While the initial implementation in GHC 9.10 had some important limitations, the situation has since been much improved. Provided you use GHC 9.12 or later, there are two things to pay attention to in your own code (these apply to 9.12, 9.14 and 10.0):
- Define your own custom function to display exceptions, which shows all annotations, not just the top-level ones (or use mine).
- Be cautious with throwing
SomeException:toExceptionforSomeExceptionwill clear the exception context, which is almost certainly not what you want. For catch-and-rethrow, use the combinators available specifically for that purpose.
That said, there are still a few minor shortcomings to be aware of:
GHC 9.12 and 9.14:
- Exception handling in STM has not yet been updated:
throwSTMdoes not collect a backtrace, andcatchSTMdoes not add anyWhileHandlingannotations (#25365). onExceptiondoes not add anyWhileHandlingexceptions; as a result, if the resource deallocation callback tobracketitself throws an exception, the original exception will be lost.
Both of these will be addressed in GHC 10.0.
- Exception handling in STM has not yet been updated:
exceptions-0.10.9: this is the version of
exceptionsthat is bundled with GHC 9.12, but lags behind a bit. For example, the definition ofgeneralBracketinexceptions-0.10.9does not use any of the abstractions for rethrowing; this is fixed inexceptions-0.10.12. The impact is however limited: it merely means that there are some extraneousWhileHandlingannotations, resulting in unnecessary noise.Any catch-and-rethrow patterns implemented in other packages should not lose any annotations, provided that they use
catchfrombase.
We will ignore calls to
withFrozenCallStack, which hide some internal functions from theHasCallStackbacktrace. This makes the backtrace slightly more readable, but does not otherwise change anything. See CLC #387.↩︎Some versions of
basedistinguish betweencatchExceptionNoPropagateandcatchNoPropagate, which differ only in some strictness annotations. Strictness can make a big difference, especially when IO actions areundefinedrather than throwing an exception. However, this is its own can of worms, and outside the scope of this blog post. See CLC proposal #383 for some discussion.↩︎In GHC 9.10,
displayExceptiondid show annotaitons, but this got rolled back in 9.12; see CLC #285 for a detailed discussion.↩︎