Skip to content

Do not discard exception context #166

Open

Description

@guibou

GHc 9.12 introduced rethrowIO as well as catchNoPropagate and tryWithContext. The interesting part of rethrowIO is that:

  • It does not introduces a new stack trace as exception annotation
  • It does not clean the previous stack trace and exception annotations.

For example, the following code:

{-# LANGUAGE DeriveAnyClass #-}
import Control.Concurrent.Async
import Control.Exception
import Control.Exception.Context
import Control.Exception.Annotation
import Data.Typeable
import Data.Traversable
import GHC.Stack

data Ann = Ann String
  deriving (Show, ExceptionAnnotation)

asyncTask :: HasCallStack => IO ()
asyncTask = annotateIO (Ann "bonjour") $ do
  error "yoto"

asyncTask' :: HasCallStack => IO ()
asyncTask' = annotateIO (Ann "bonjour2") $ do
  error "yutu"

main = do
  -- withAsync asyncTask wait
  concurrently asyncTask asyncTask'
  -- race asyncTask asyncTask'

Results in the following non informative output:

ASyncException.hs: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

yoto

HasCallStack backtrace:
  throwIO, called at ./Control/Concurrent/Async/Internal.hs:630:24 in async-2.2.5-50rpfAJ7BEc1o5OswtTMUN:Control.Concurrent.Async.Internal

Especially:

  • The backtrace points to the internal of async, completly losing the "real" and relevant backtrace
  • The exception annoattion is lost

If run without the async "wrapping", we get instead:

ASyncException.hs: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

yoto

Ann "bonjour"
HasCallStack backtrace:
  error, called at ASyncException.hs:15:3 in main:Main
  asyncTask, called at ASyncException.hs:24:3 in main:Main

With annotation and backtrace.

This could be fixed by using rethrowIO instead of throw everywhere an exception is rethrown in async. See the attached MR which can be used as a proof of concept. A bit of discussion may be necessary to know if we want to add a WhileHandling context before rethrowing.

Activity

  1. mpickering commented on Jan 8, 2026

    @mpickering

    It would be good to do a release which implements this change. You could get better backtraces when using async if this was implemented.

  2. mpickering commented on Jan 8, 2026

    @mpickering

    Note that there is the additional issue that throwSTM does not attach backtrace information, in particular waitSTM rethrows an exception using throwSTM so it gets no context at all.

    As a rule of thumb, anywhere an exception is caught and rethrown, use rethrowIO rather than throwIO. We probably also need rethrowSTM for this use case.

  3. guibou commented on Jan 31, 2026

    @guibou
    Author

    (Sorry, this was a comment intended for #165)

    I'm a bit annoyed.

    Take this code sample:

    data Ann = Ann String
      deriving (Show, ExceptionAnnotation)
    
    data Exc' = Exc' String
      deriving (Show, Exception)
    
    asyncTask :: HasCallStack => IO ()
    asyncTask = annotateIO (Ann "bonjour") $ do
      throwIO (Exc' "yoto")
    
    asyncTask' :: HasCallStack => IO ()
    asyncTask' = annotateIO (Ann "bonjour2") $ do
      throwIO (Exc' "yutu")
    
    t = do
      withAsync asyncTask $ \t ->
        wait t
      -- concurrently asyncTask asyncTask'

    The current output is:

    *** Exception: Exc' "yoto"
    
    While handling Exc' "yoto"
    
    Ann "bonjour"
    HasCallStack backtrace:
      throwIO, called at test/test-async.hs:39:3 in async-2.2.5-inplace-test-async:Main
      asyncTask, called at test/test-async.hs:46:13 in async-2.2.5-inplace-test-async:Main
    

    Here I'm annoyed by:

    • The present of While handling, which is added by the wait call, but that's still unclear why to me.
    • The CallStack does NOT show anything related to the wait location, it only shows the callsite for asyncTask.

    I think the correct solution would be to add some sort of While waiting at exception annotation on wait, adding another HasCallStack backtrace for the wait location ?

  4. simonmar commented on Feb 3, 2026

    @simonmar
    Owner

    I'm rather opposed to adding HasCallStack anywhere in the core API. We already have a compile-time flag debug-auto-label that adds HasCallStack and that's where I think it should stay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions