Exception Annotations: Lay of the Land

New blog post which gives a comprehensive overview of the exception annotation infrastructure, which has been much improved in GHC 9.12, and discuss some things to be aware of. TL;DR: Use ghc 9.12, don’t call throwIO on an argument of type SomeException, and use your own function instead of displayException if you want to see all exception annotations.

37 Likes

Great overview, thanks!

About the issue of whether, in the Exception instance for SomeException, the definition of toException should clear the context.

If it clears the context (as it does currently) this code

catchAndThrow :: IO a -> IO a
catchAndThrow = handle $ \(e :: SomeException) -> throwIO e

will lose the existing anotations in e, instead adding backtrace information (say, an IPE backtrace) corresponding to the throwIO, correct?

If we modify SomeException’s toException to keep the context, wouldn’t that same code result in two unrelated backtraces, one for the original exception, another for the throwIO call site? :thinking:

2 Likes

At the risk of confusing myself and others :sweat_smile: (this is full of subtle issues!)

Suppose we made the toException of both SomeException and ExceptionWithContext actually respect the “produce a SomeException with no attached ExceptionContext” invariant. No exceptions (pun accidental) for it!

Suppose as well that we added a separate toExceptionPreservingContext :: ExceptionWithContext e -> SomeException function, and also redefined rethrowIO as

rethrowIO :: Exception e => ExceptionWithContext e -> IO a
rethrowIO e = throwIO (NoBacktrace (toExceptionPreservingContext e))

Would that work / be better? :thinking: Instance definitions would be more uniform, at least.

I don’t know what the original motivation was for clearing the context of SomeException; if someone does, please do let me know, I’d love to know. Now that we are here though, I hesitate to argue that we should stop doing that; there are a lot of puzzle pieces here that need to fit together just so, and it’s difficult to be sure what happens when we change something quite so central as SomeException itself. Your toExceptionPreservingContext proposal at least separates out these concerns; if we conclude that the behaviour of toException for SomeException is the right one, then perhaps something like that might be useful. It would still be quite confusing though that throwIO (ExceptionWithContext ctxt e) would not actually use that context ctxt though! Personally I think we should change the behaviour and simply drop the requirement that the context must be empty, but it’s hard to be sure.

As for your question about the two backtraces: yes, you are correct. That’s why we have catchNoPropagate and rethrowIO.

2 Likes

I don’t know why the invariant that toException must drop the context was added. Maybe it was added to make the behaviour of existing exception-rethrowing code outside of base more uniform?

To recap. In modern base, we have that specialized rethrowIO function that does the right thing™ to propagate the context when rethrowing. All rethrowing code in base will use it. However, outside of base, we’ll have lots of code like

catchAndThrow :: IO a -> IO a
catchAndThrow = handle $ \(e :: SomeException) -> throwIO e

catchAndThrowIOException :: IO a -> IO a
catchAndThrowIOException = handle $ \(e :: IOException) -> throwIO e

that performs rethrows using the old throwIO. Rethrows of SomeException, but also rethrows of some concrete exception like IOException.

If we don’t require toException to drop the exception context:

  • The exception that comes out of catchAndThrow will have two backtraces, the one from the original SomeException and the one added by the throwIO. Is that bad? Perhaps we could tolerate it and, by convention, assume that the last backtrace in the exception context is the original one. But perhaps it would confuse tooling like the new debugger. :thinking: As code begins to migrate to rethrowIO, double backtraces would become rarer.

  • The exception that comes out of catchAndThrowIOException will have only one backtrace, the one added by the throwIO, because concrete exceptions don’t carry a context. We would need to switch to rethrowIO to keep the original backtrace. Is that lack of uniformity with respect to SomeException bad?

Alternatively, if we require toException to drop the exception context, in both catchAndThrow and catchAndThrowIOException the outgoing exception will only have the context added by the throwIO. Again, we would need to switch to rethrowIO to keep the original backtrace instead. And I would prefer toException to drop the context even for ExceptionWithContext, and have that separate toExceptionPreservingContext function mentioned earlier.

  • SomeException and ExceptionWithContext rethrows with throwIO won’t include the original context. That would admittedly be a bit unintuitive.
  • But it’s more uniform with re-throwing other types of exceptions using throwIO. A single “if you want the original context, switch to rethrowIO!” slogan applicable in all cases, instead of “well, you can switch to rethrowIO, but in the particular case of SomeException and ExceptionWithContext you might not need to do so, it depends…”
  • Duplicate backtraces won’t ever happen.
1 Like

Personally I think the fact that toException clears the exception context is Very Bad ™. Yes, if you happen to rethrow the exception inside a catch, then the WhileHandling saves you. And I agree, unnecessary WhileHandling annotations are annoying but ultimately harmless, and over time they will become less (indeed, I just submitted a patch against async which removes one example).

But–critically–exceptions are not always rethrown in the context of an exception handler; for example, consider waitSTM; this gets a previously thrown exception out of a TMVar, and rethrows it; since it uses throwSTM, and since it’s SomeException, all annotations on that exception are now irrevocably lost.

I completely agree. I remember advocating heavily for the adoption of being part of a lot of the design discussions around this feature (Original blog post, the library as it is today), and I do not at all recall this being a thing anyone needed or wanted.

annotated-exceptionworks today and on older GHCs and does not contain these warts nearly as much; but it is unfortunately limited for library development because it forces all consumers of your library to adopt annotated-exception and deal with the AnnotatedException wrapper. My next plan for the library is to figure out how to incorporate the new ExceptionContext changes without ruining the utility and UX of the library.

3 Likes

Embarrassingly, I hadn’t considered WhileHandling then thinking about the rethrow behaviours. I was thinking about the possibility of ending up with multiple Backtraces annotations in the same annotation list.

I’ve created the this repo to experiment (using GHC 9.14.1)

It has three cases:

catchAndThrow :: IO a -> IO a
catchAndThrow action = catch @SomeException action throwIO

-- like 'catchAndThrow', but with a type of exception for which 'toException' 
-- preserves context, here we used 'ExceptionWithContext'
catchAndThrow' :: IO a -> IO a
catchAndThrow' action = catch @(ExceptionWithContext SomeException) action throwIO


-- after migrating to 'catchNoPropagate' + 'rethrowIO'
catchAndRethrow :: IO a -> IO a
catchAndRethrow action = catchNoPropagate @(ExceptionWithContext SomeException) action rethrowIO

In the first case, the exception structure looks like

IOException
|
+- WhileHandling
|  |
|  `- IOException
|     |
|     `- Backtrace: HasCallStack backtrace:/  throwIO, called at app/Main.hs:30:7 ...
|
`- Backtrace: HasCallStack backtrace:/  throwIO, called at app/Main.hs:15:62 ...

The original exception is inside the WhileHandling annotation, with the original backtrace. The rethrown exception has the backtrace of the rethrow.

For the second case (the exception whose toException doesn’t clean the context) we have

IOException
|
+- WhileHandling
|  |
|  `- IOException
|     |
|     `- Backtrace: HasCallStack backtrace:/  throwIO, called at app/Main.hs:30:7 ...
|
+- Backtrace: HasCallStack backtrace:/  throwIO, called at app/Main.hs:20:84 ...
|
`- Backtrace: HasCallStack backtrace:/  throwIO, called at app/Main.hs:30:7 ...

The WhileHandling is still there, but the rethrown exception (confusingly?) has two Backtraces annotations.

In the third case, the rethrow is not visible in the exception, we only have the original Backtraces:

IOException
|
`- Backtrace: HasCallStack backtrace:/  throwIO, called at app/Main.hs:30:7 ...
1 Like