Skip to content

feat: support for exception context propagation#165

Open
guibou wants to merge 8 commits intosimonmar:masterfrom
novainsilico:no_discard_exception_context
Open

feat: support for exception context propagation#165
guibou wants to merge 8 commits intosimonmar:masterfrom
novainsilico:no_discard_exception_context

Conversation

@guibou
Copy link
Copy Markdown

@guibou guibou commented May 10, 2025

This change is in order to have exception context propagation.

Everywhere we "re"-throw exception with throw, we instead use rethrow which does not remove the exception context. I've introduced rethrowIO' which is just a backward compatibility wrapper over rethrowIO and throwIO.

I will use this commit at work for a bit of time in order to gather some feedback and maybe comeback with a more robust solution.

Example of usage / changes:

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'

When run without this commit leads to:

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

When run with this commit:

*** Exception: yoto

Ann "bonjour"
HasCallStack backtrace:
  error, called at /home/guillaume//ASyncException.hs:15:3 in async-2.2.5-inplace:Main
  asyncTask, called at /home/guillaume//ASyncException.hs:23:16 in async-2.2.5-inplace:Main

@guibou guibou force-pushed the no_discard_exception_context branch from aa03c27 to 91c00c5 Compare May 10, 2025 06:55
@mrkline
Copy link
Copy Markdown

mrkline commented Aug 27, 2025

Is there anything else that needs to be done while this is WIP? It would be excellent for exception annotations to propagate through.

@guibou
Copy link
Copy Markdown
Author

guibou commented Aug 27, 2025

@mrkline

Sorry, the only reason is that I had not took time to work on that.

I'm using this MR since month at work and did not had any surprising behavior.

Could be turned to "ready", if it builds with older GHC (I think so), I don't see any reason why not processing any further. Even if I missed some proper rethrow, it can be added in a future work.

@guibou guibou marked this pull request as ready for review August 27, 2025 12:41
@mrkline
Copy link
Copy Markdown

mrkline commented Aug 28, 2025

No worries, and thanks for the work!

I tried backporting this to base-4.20 so that it could also work with LTS-24. (mrkline@bb9d703) I hoped it would be simple since

rethrowIO e = throwIO (NoBacktrace e)

But sadly GHC 9.10 seems noisier - your example code gives

async-test: yoto
CallStack (from HasCallStack):
  error, called at Main.hs:15:3 in tacview-0.4.2.0-inplace-async-test:Main
  asyncTask, called at Main.hs:23:16 in tacview-0.4.2.0-inplace-async-test:Main
Ann "bonjour"
HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:169:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:204:5 in ghc-internal:GHC.Internal.Exception
  error, called at Main.hs:15:3 in tacview-0.4.2.0-inplace-async-test:Main
  asyncTask, called at Main.hs:23:16 in tacview-0.4.2.0-inplace-async-test:Main


HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:169:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:260:11 in ghc-internal:GHC.Internal.IO
  throwIO, called at libraries/ghc-internal/src/GHC/Internal/Control/Exception/Base.hs:195:43 in ghc-internal:GHC.Internal.Control.Exception.Base

If you spot any obvious problem, let me know.

@guibou
Copy link
Copy Markdown
Author

guibou commented Sep 2, 2025

No worries, and thanks for the work!

I tried backporting this to base-4.20 so that it could also work with LTS-24. (mrkline@bb9d703) I hoped it would be simple since

rethrowIO e = throwIO (NoBacktrace e)

My understanding of NoBacktrace is that it is useful at catch site, to remove the backtrace, but not at throw site.

As always with exception, I need to experiment to really understand what is going on (I have an infinite respect for people who can reason about exception in their head without experimenting ;)

If you spot any obvious problem, let me know.

I need to get back into this MR actually.

It seems that your branch mrkline@bb9d703 is based on one commit that is not what's inside this MR. Your commit is bb9d703, which is really alike the commit on my local repo, 5cbf92365b4501d9c102b810e8f6159b778ae615, they all are on branch exception-context, however this MR is the branch novainsilico:no_discard_exception_context.

I'm lost in the history there, sorry, it may be my fault (not worked on this MR since a long time and I'm used to try a lot of thing locally, change branch, force push, ...). Do you remember how you ended with commit bb9d703 and branch exception-context?

@guibou
Copy link
Copy Markdown
Author

guibou commented Sep 2, 2025

@mrkline

Using GHC 9.12:

Indeed, throwIO with NoBacktrace does not include backtrace.

λ gecko ~ → cat Ex.hs 
import Control.Exception

main = do
  throwIO (NoBacktrace (ErrorCall "test"))
  -- throwIO (ErrorCall "test")
λ gecko ~ → runhaskell Ex.hs 
Ex.hs: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

test

With no NoBacktrace, there is a backtrace:

λ gecko ~ → cat Ex.hs       
import Control.Exception

main = do
  -- throwIO (NoBacktrace (ErrorCall "test"))
  throwIO (ErrorCall "test")
λ gecko ~ → runhaskell Ex.hs
Ex.hs: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

test

HasCallStack backtrace:
  throwIO, called at Ex.hs:5:3 in main:Main

(So I'm wrong, throwing with NoBacktrace does indeed not include the backtrace).

However, throwing an exception caught with ExceptionWithContext leads to a duplication of the backtrace, if NoBacktrace is not there:

λ gecko ~ → cat Ex.hs       
import Control.Exception

main = do
  -- throwIO (NoBacktrace (ErrorCall "test"))
  Left e <- try @(ExceptionWithContext SomeException) (throwIO (ErrorCall "test"))
  throwIO (NoBacktrace e)

λ gecko ~ → runhaskell Ex.hs
Ex.hs: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

test

HasCallStack backtrace:
  throwIO, called at Ex.hs:5:56 in main:Main
λ gecko ~ → cat Ex.hs       
import Control.Exception

main = do
  -- throwIO (NoBacktrace (ErrorCall "test"))
  Left e <- try @(ExceptionWithContext SomeException) (throwIO (ErrorCall "test"))
  throwIO e

λ gecko ~ → runhaskell Ex.hs
Ex.hs: Uncaught exception ghc-internal:GHC.Internal.Exception.ErrorCall:

test

HasCallStack backtrace:
  throwIO, called at Ex.hs:6:3 in main:Main

HasCallStack backtrace:
  throwIO, called at Ex.hs:5:56 in main:Main

So as a first approximation, it seems that your implementation should be correct.

I'll continue the experimentation, maybe that's different with ghc 9.10.

@guibou
Copy link
Copy Markdown
Author

guibou commented Sep 2, 2025

With GHC 9.10.2:

λ gecko ~ → cat Ex.hs
import Control.Exception
import GHC.Internal.Exception.Type

main = do
  -- throwIO (NoBacktrace (ErrorCall "test"))
  Left e <- try @(ExceptionWithContext SomeException) (throwIO (ErrorCall "test"))
  throwIO (NoBacktrace e)

λ gecko ~ → runhaskell Ex.hs
Ex.hs: test
HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:169:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:260:11 in ghc-internal:GHC.Internal.IO
  throwIO, called at libraries/exceptions/src/Control/Monad/Catch.hs:308:12 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at compiler/GHC/Driver/Monad.hs:167:54 in ghc-9.10.2-ef99:GHC.Driver.Monad
  a type signature in an instance, called at compiler/GHC/Driver/Monad.hs:167:54 in ghc-9.10.2-ef99:GHC.Driver.Monad
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at ghc/GHCi/UI/Monad.hs:288:15 in ghc-bin-9.10.2-4bb7:GHCi.UI.Monad
  a type signature in an instance, called at ghc/GHCi/UI/Monad.hs:288:15 in ghc-bin-9.10.2-4bb7:GHCi.UI.Monad
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/haskeline/System/Console/Haskeline/InputT.hs:53:39 in haskeline-0.8.2.1-0f10:System.Console.Haskeline.InputT
  a type signature in an instance, called at libraries/haskeline/System/Console/Haskeline/InputT.hs:53:39 in haskeline-0.8.2.1-0f10:System.Console.Haskeline.InputT
  throwM, called at ghc/GHCi/UI/Monad.hs:215:52 in ghc-bin-9.10.2-4bb7:GHCi.UI.Monad


λ gecko ~ → cat Ex.hs       
import Control.Exception
import GHC.Internal.Exception.Type

main = do
  -- throwIO (NoBacktrace (ErrorCall "test"))
  Left e <- try @(ExceptionWithContext SomeException) (throwIO (ErrorCall "test"))
  throwIO e

λ gecko ~ → runhaskell Ex.hs
Ex.hs: test
HasCallStack backtrace:
  collectBacktraces, called at libraries/ghc-internal/src/GHC/Internal/Exception.hs:169:13 in ghc-internal:GHC.Internal.Exception
  toExceptionWithBacktrace, called at libraries/ghc-internal/src/GHC/Internal/IO.hs:260:11 in ghc-internal:GHC.Internal.IO
  throwIO, called at libraries/exceptions/src/Control/Monad/Catch.hs:308:12 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at compiler/GHC/Driver/Monad.hs:167:54 in ghc-9.10.2-ef99:GHC.Driver.Monad
  a type signature in an instance, called at compiler/GHC/Driver/Monad.hs:167:54 in ghc-9.10.2-ef99:GHC.Driver.Monad
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at ghc/GHCi/UI/Monad.hs:288:15 in ghc-bin-9.10.2-4bb7:GHCi.UI.Monad
  a type signature in an instance, called at ghc/GHCi/UI/Monad.hs:288:15 in ghc-bin-9.10.2-4bb7:GHCi.UI.Monad
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/exceptions/src/Control/Monad/Catch.hs:427:21 in exceptions-0.10.9-f51c:Control.Monad.Catch
  throwM, called at libraries/haskeline/System/Console/Haskeline/InputT.hs:53:39 in haskeline-0.8.2.1-0f10:System.Console.Haskeline.InputT
  a type signature in an instance, called at libraries/haskeline/System/Console/Haskeline/InputT.hs:53:39 in haskeline-0.8.2.1-0f10:System.Console.Haskeline.InputT
  throwM, called at ghc/GHCi/UI/Monad.hs:215:52 in ghc-bin-9.10.2-4bb7:GHCi.UI.Monad


λ gecko ~ → 

There is no difference between NoBacktrace and no NoBacktrace.

This is surprising.

@guibou
Copy link
Copy Markdown
Author

guibou commented Sep 2, 2025

Haa, I think I got it. The problem is that in newest base, ErrorCall does not duplicate the callstack.

From base 4.12 changelog: https://hackage.haskell.org/package/base-4.21.0.0/changelog

Get rid of the HasCallStack mechanism manually propagated by ErrorCall in favour of the more general HasCallStack exception backtrace mechanism, to remove duplicate call stacks for uncaught exceptions.

So the reason for the duplicate call stack is that it is part of the context AND part of ErrorCall.

To be honest, I think we can keep that. This is painful, indeed, but that's part of a work in progress in the exception handling logic. Adding more complexity would, IMHO, just lead to problem in the future.

@mpickering
Copy link
Copy Markdown

mpickering commented Jan 8, 2026

@guibou It would be great to merge this, what's the latest? @simonmar would you have time soon to look at this PR and to potentially merge it?

@mpickering
Copy link
Copy Markdown

Also bear in mind that throwSTM doesn't attach a backtrace currently - https://gitlab.haskell.org/ghc/ghc/-/merge_requests/13408#note_652255

@simonmar
Copy link
Copy Markdown
Owner

simonmar commented Jan 8, 2026

Seems reasonable. Could we have a test please?

@guibou
Copy link
Copy Markdown
Author

guibou commented Jan 18, 2026

Seems reasonable. Could we have a test please?

I'm doing that right now.

@guibou
Copy link
Copy Markdown
Author

guibou commented Jan 18, 2026

I do have a question for you, regarding the test.

The idea of test is to throw an exception in an async context (e.g. concurently thisThrow thisDoesNotThrow) and see if the backtrace pass through the concurrently call AND continue inside thisThrow or if it stops at the (re)throw inside concurrently.

Exceptions and backtrace are an opaque feature, we can get the backtrace, display it (see https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Backtrace.html#t:Backtraces), but nothing more without using a GHC.Internal module. And unfortunately, the displayed value contains volatile items (such as the line numbers).

My idea was to compare the exception caught from concurently to an exception caught directly by calling thisThrow. However part of the callstack will be the same, but the rest won't.

Is it OK if I just pick one line of the backtrace and compare (I've tested, it works), and we'll fix later if it break with a future GHC version, or should I go a bit deeper inside the GHC.Internal relevant module in order to extract more precise informations, at the risk of more breakage with next GHC version?

I'll continue with the first approach (e.g. working on the displayBacktraces bt :: String value and manual extraction) and submit an extended MR, but in the meantime if you think that the second solution is better, let me know.

@guibou guibou force-pushed the no_discard_exception_context branch from d374da2 to d0f21f8 Compare January 18, 2026 13:20
@guibou
Copy link
Copy Markdown
Author

guibou commented Jan 18, 2026

Please take a look at the newly added tests.

Before merging, give me a bit of time. I would like to see if we can add context in the "non-local" operations so the rethrow location is not wasted. e.g. when running withAsync action $ \async -> .... wait async it would be great to have the callstack inside action as well as the callstack toward wait)

@mpickering
Copy link
Copy Markdown

Thanks @guibou, looks good to me. In theory you should be able to use ghc-experimental to inspect the Backtraces object but the patch for this didn't make it into 9.14.

@guibou guibou force-pushed the no_discard_exception_context branch from d0f21f8 to c16d82d Compare January 31, 2026 12:15
@guibou
Copy link
Copy Markdown
Author

guibou commented Jan 31, 2026

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 presence of While handling, which is added by the wait call, but that's still unclear why to me. edit: actually, there is another catch inside the withAsync block which recatch the exception thrown by wait and wraps with While handling. That's definitely a behavior which should be improved.
  • 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 ?

@guibou guibou force-pushed the no_discard_exception_context branch from c16d82d to 3064e24 Compare January 31, 2026 12:21
@guibou
Copy link
Copy Markdown
Author

guibou commented Jan 31, 2026

In the meantime, I've rebased the branch AND cleaned build with ghc 9.8 -> 9.14.

@guibou guibou force-pushed the no_discard_exception_context branch 2 times, most recently from fb09087 to 3d5cc30 Compare January 31, 2026 13:08
@guibou
Copy link
Copy Markdown
Author

guibou commented Jan 31, 2026

@simonmar I've fixed the While Handling annotation which was appearing with withAsync blocks.

Regarding the fact that the "non-local" primitive do not appear in the callstack (e.g. wait, waitBoth, waitAny). I've also added an exception annotation which shows the location of the wait call.

For example, using the previous example, an exception will now be displayed with a callstack locating the exception original throw, all of the annotations, AND a new annotation showing the location of the wait call:

*** Exception: Exc' "yoto"

AsyncWaitLocation CallStack (from HasCallStack):
  wait, called at test/test-async.hs:49:5 in async-2.2.6-inplace-test-async:Main
Ann "bonjour"
HasCallStack backtrace:
  throwIO, called at test/test-async.hs:41:3 in async-2.2.6-inplace-test-async:Main
  asyncTask, called at test/test-async.hs:48:13 in async-2.2.6-inplace-test-async:Main

I've tested that it builds and run tests with GHC 9.8 -> 9.14.

There are no tests for the new annotation for wait location feature. Give me a bit of time and I'll craft something.

@Bodigrim
Copy link
Copy Markdown

Bodigrim commented Mar 1, 2026

Just to say that I'm eagerly awaiting for this patch. I've been debugging some code with stack traces ending at rethrowing in withAsyncUsing - and it's no fun.

@guibou
Copy link
Copy Markdown
Author

guibou commented Mar 1, 2026

Just to say that I'm eagerly awaiting for this patch. I've been debugging some code with stack traces ending at rethrowing in withAsyncUsing - and it's no fun.

Thank you @Bodigrim, I take that as words of support for my work.

I'm under heavy load right now (work and personal issues) and finishing this MR is difficult for me. The main code is here, tests are there, except for one behavior which is difficult to test: that wait does indeed returns the correct callstack annotation + another annotation for the wait location callstack. I think I can do it if I take a bit of time, but I think it can also be reviewed and merged without too.

I'll try to give that a shoot this week, but I cannot promise (and I don't want to make promise I won't be able to fullfil).

@Bodigrim
Copy link
Copy Markdown

Bodigrim commented Mar 1, 2026

@simonmar could you possibly take a look please?

@guibou
Copy link
Copy Markdown
Author

guibou commented Mar 1, 2026

Sorry for the bad mood comment. I just gave it another look and realized that I had already wrote the test a few weeks ago and never just polished it.

I've pushed a new commit with the missing test.

From my point of view, this MR is ready for review and hopefully merge.

Thank you for your support.

@Bodigrim
Copy link
Copy Markdown

Bodigrim commented Mar 7, 2026

Just had another run in into a trace ending up at throwIO in withAsyncUsing, this time while debugging Cabal. From my perspective it would be most helpful to have this patch merged and released. Gentle ping @simonmar.

var <- newEmptyTMVarIO
mask $ \restore -> do
let action_plus = debugLabelMe >> action
t <- doFork $ try (restore action_plus) >>= atomically . putTMVar var
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not we use tryWithContext here? Otherwise I imagine we are losing stack traces from action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say yes. If any kind of custom error handling & rethrowing is done, I don't think any use of "old" try, catch etc combinators is correct. You should always use tryWithContext, catchNoPropogate etc to capture the context.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to explain the context in more details here: #165 (comment)

In summary:

  • This try still catch the context and put it in the SomeExeption which is "magically" recovered later using the ugly rethrowIO' logic.
  • This is done in order to limit the complexity of the refactor

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phadej, @Bodigrim, would you have a look at the latest commit which tries a different approach: using tryWithContext / catchNoPropagate everywhere and adding a simple compatibility layer.

The test fails with GHC 9.10, I'm investigating, but I would like to know first if you prefer this approach.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the new version better.

One caveat is that Async fields are exported from Control.Concurrent.Async.Internal. The module is declared as internal and not adhering to PVP, so changing _asyncWait to contain ExceptionWithContext SomeException instead of just SomeException is legal. Yet in such case I suggest re-exporting ExceptionWithContext from the same module, otherwise anyone looking to use it would have to reimplement exactly the same compatibility layer for it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this suggestion. Please tell me is the latest commit I just pushed is what you were waiting for.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant to re-export just ExceptionWithContext (because it's a part of Async), but what you've done works for me as well; thanks.

Bodigrim added a commit to UnkindPartition/tasty that referenced this pull request Mar 22, 2026
There are two parts:

* Catch and rethrow exceptions with context in our vendored-in fork of `async`.
  This is inspired by upstream PR simonmar/async#165.
* Bridge over multiple changes to `displayException` in `base`
  to ensure that at least some stack trace is shown.
Bodigrim added a commit to UnkindPartition/tasty that referenced this pull request Mar 24, 2026
There are two parts:

* Catch and rethrow exceptions with context in our vendored-in fork of `async`.
  This is inspired by upstream PR simonmar/async#165.
* Bridge over multiple changes to `displayException` in `base`
  to ensure that at least some stack trace is shown.
Bodigrim added a commit to UnkindPartition/tasty that referenced this pull request Mar 24, 2026
There are two parts:

* Catch and rethrow exceptions with context in our vendored-in fork of `async`.
  This is inspired by upstream PR simonmar/async#165.
* Bridge over multiple changes to `displayException` in `base`
  to ensure that at least some stack trace is shown.
@simonmar
Copy link
Copy Markdown
Owner

would you mind resolving the conflicts and resubmitting so that the CI jobs will run please?

Left ex -> rethrowIO' ex
Right r -> collect (r:xs) m

concurrently' ::
Copy link
Copy Markdown
Contributor

@phadej phadej Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, concurrently' is an internal, not exposed combinator, so I suggest that it's changed from

concurrently' ::
  CALLSTACK
  IO a -> IO b
  -> (IO (Either SomeException (Either a b)) -> IO r)
  -> IO r

to

concurrently' ::
  CALLSTACK
  IO a -> IO b
  -> (IO (Either (ExceptionWithContext SomeException) (Either a b)) -> IO r)
  -> IO r

i.e. catch -> catchNoPropagate change. If I'm looking right, that would remove the need for very suspicious looking rethrowIO', the base's rethrowIO will be enough.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, catchAll in the body should probably be catchNoPropogate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phadej I agree with your proposal, but it mean that the complete concurrently' block would be duplicated with a #if MIN_VERSION_base(4,21,0) pragma. And similarly, if you want to avoid the rethrowIO' function, we also need to duplicate all the rethrowIO' callsite to dispatch between rethrowIO and throwIO. This is a lot of code duplication, unless I missunderstood your proposal.

Note that I do admit that rethrowIO' is suspicious, but it is well localised and seems safe because SomException may contain an exception context (Actually, it will always contain one, unless it was explicitely removed).

Regarding catchAll versus catchNoPropagate. I would claim that in this context we do not really care.

The point of catchNoPropagate is for the handler to possibly rethrow the exception explicitely and hence not having the WhileHandling wrapping an a new callstack. Here the handler is a putMVar done . Left which does not explicitely rethrow the exception (hence the WhileHandling won't happen), unless another exception happen during the putMVar and I would admit that this context is, from my understanding, rare or even impossible, but actually not a "normal" use case of the async library, and does not fall into the the scope of this MR (e.g. improving exception context in "normal" use case, meaning when an exception happen in the task run by the library, no in its internals).

The "problem" with catchNoPropagate is that it returns an ExceptionWithContext and if we want to store that inside the mvar, we actually need to patch all the library.

I think my approach is imperfect because of backward compatibility. When later we'll drop support for base <4.21, we'll be able to rewrite the complete library and turns all the rethrowIO' to proper rethrowIO and all the SomeException to proper ExceptionWithContext SomeException, but this is not doable immediately without tones of CPP.

@phadej I see a few options here:

a) Keep the implementation as it is right now
b) Introduce a lot of CPP everywhere
c) Copy paste everything in another module and do conditional build based on the base version
d) Drop support for base <4.21 and do a "major" async release and maybe maintain the old one in a different branch with a different version number?
e) (I just thought about it): use CPP only in a really localised place to implement rethrowIO, catchNoPropagate and ExceptionWithContext which would map either to throwIO, catch and SomeException or the version from base>= 4.21. Actually, this may be the best option. Do you want me to attempt this refactor?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phadej, @Bodigrim, would you have a look at the latest commit which tries a different approach: using tryWithContext / catchNoPropagate everywhere and adding a simple compatibility layer.

The test fails with GHC 9.10, I'm investigating, but I would like to know first if you prefer this approach.

Bodigrim added a commit to UnkindPartition/tasty that referenced this pull request Mar 24, 2026
There are two parts:

* Catch and rethrow exceptions with context in our vendored-in fork of `async`.
  This is inspired by upstream PR simonmar/async#165.
* Bridge over multiple changes to `displayException` in `base`
  to ensure that at least some stack trace is shown.
guibou added 5 commits March 28, 2026 10:26
We specialize the `throwIO` call using a newly implemented `rethrowIO'`
which behaves as `rethrowIO` from base 4.21 when available or like the
previous `throw` implementation.

In short:

- Before `base-4.21`, the code is exactly as before
- After `base-4.21`, the code does not override the backtrace
  annotations and instead uses `rethrowIO`.

Example of usage / changes:

The following code:

```haskell
{-# 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'
```

When run without this commit leads to:

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

When run with this commit:

```
*** Exception: yoto

Ann "bonjour"
HasCallStack backtrace:
  error, called at /home/guillaume//ASyncException.hs:15:3 in async-2.2.5-inplace:Main
  asyncTask, called at /home/guillaume//ASyncException.hs:23:16 in async-2.2.5-inplace:Main
```
Previous commits extended the exception annotations so the default
callstack represents the exception location of the original exception in
the async process. This callstack also includes where the async process
was started (e.g. in `withAsync`).

This commits extends the exception context by adding a new
`AsyncWaitLocation` exception annotation which contains the location of
the `wait` call.

Note the usage of `withFrozenCallStack` in order to not expose the
internal of the async library in the callstack.
@guibou guibou force-pushed the no_discard_exception_context branch from 207f618 to 91a23d5 Compare March 28, 2026 06:28
@guibou
Copy link
Copy Markdown
Author

guibou commented Mar 28, 2026

would you mind resolving the conflicts and resubmitting so that the CI jobs will run please?

Done. Please give me a bit of time so I can address the comments of @phadej and @Bodigrim.

@guibou guibou force-pushed the no_discard_exception_context branch from 9d55314 to 5404d4d Compare March 28, 2026 10:28
@guibou
Copy link
Copy Markdown
Author

guibou commented Mar 28, 2026

New implementation approach pushed. Test are failing locally with GHC 9.10 (it is the complex context where ExceptionWithContext exists, but no rethrowIO nor catchNoPropagate so I'm certainly doing something stupid here), I'm investigating. edit: tests fixed, seems to work from ghc 9.8 to 9.14.

Do you think we should include new functions, such as pollWithContext, waitCatchWithContext which behaves as tryWithContext and returns the ExceptionWithContext ? Note that the context can already be recovered from the SomeException using fromException, so these new function can be considered as helpers but are not mandatory.

@guibou guibou force-pushed the no_discard_exception_context branch 2 times, most recently from 3012a47 to b15a982 Compare March 28, 2026 10:47
This implementation works differently than the previous one. It actually
implements all the standard function based on the GHC 9.12 api (e.g.
`tryWithContext`, `catchNoPropagate`, `rethrowIO`) and provides a simple
and localise compatibility layer which reimplements these function for
older `base`.

The implementation should be easier to read with less CPP and subtle
logic scattered everywhere.
@guibou guibou force-pushed the no_discard_exception_context branch from fdd9100 to c81b361 Compare March 28, 2026 11:06
guibou added 2 commits March 28, 2026 15:14
For easier usage of the Internal module in a backward compatible way in
other libraries.
@guibou guibou force-pushed the no_discard_exception_context branch from c81b361 to bcf84de Compare March 28, 2026 11:14
@guibou
Copy link
Copy Markdown
Author

guibou commented Mar 28, 2026

I've added an entry in the changelog which details the changes.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants