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 HasCallStack annotations
  • 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 = top

A 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
    top

Unlike 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` handlerTop

The 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 -> String

An important instance of this class is Backtraces, which wraps a set of different kinds of backtraces:

instance ExceptionAnnotation Backtraces where
    displayExceptionAnnotation = Base.displayBacktraces

Exception 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 a

There 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 -> ExceptionContext

Pivotal 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 -> SomeException

However, 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 _ = True

The 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 e

This 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 -> SomeException

Prior to 9.10, the default implementation literally just added the SomeException constructor:

  -- implementation prior to 9.10
  toException = SomeException

However, 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 e

Unfortunately, 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 = se

Now, 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 e

I 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 implementation

ExceptionWithContext

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 a

The 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 . toException

That 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# se

However, 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 e

those 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:

  1. It does not use toException of the underlying type (the a type parameter); in most cases this does not matter, because toException rarely does anything interesting. Even in the case of SomeException, where toException does 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 where toException genuinely does something important (even if I am not aware of any such cases currently).
  2. In the specific case that a is SomeException, this will create a nested SomeException: 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 e

then 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 = middle

With 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: toException for SomeException will 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: throwSTM does not collect a backtrace, and catchSTM does not add any WhileHandling annotations (#25365).
    • onException does not add any WhileHandling exceptions; as a result, if the resource deallocation callback to bracket itself throws an exception, the original exception will be lost.

    Both of these will be addressed in GHC 10.0.

  • exceptions-0.10.9: this is the version of exceptions that is bundled with GHC 9.12, but lags behind a bit. For example, the definition of generalBracket in exceptions-0.10.9 does not use any of the abstractions for rethrowing; this is fixed in exceptions-0.10.12. The impact is however limited: it merely means that there are some extraneous WhileHandling annotations, resulting in unnecessary noise.

  • Any catch-and-rethrow patterns implemented in other packages should not lose any annotations, provided that they use catch from base.


  1. We will ignore calls to withFrozenCallStack, which hide some internal functions from the HasCallStack backtrace. This makes the backtrace slightly more readable, but does not otherwise change anything. See CLC #387.↩︎

  2. Some versions of base distinguish between catchExceptionNoPropagate and catchNoPropagate, which differ only in some strictness annotations. Strictness can make a big difference, especially when IO actions are undefined rather 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.↩︎

  3. In GHC 9.10, displayException did show annotaitons, but this got rolled back in 9.12; see CLC #285 for a detailed discussion.↩︎