feat: support for exception context propagation#165
feat: support for exception context propagation#165guibou wants to merge 8 commits intosimonmar:masterfrom
Conversation
aa03c27 to
91c00c5
Compare
|
Is there anything else that needs to be done while this is WIP? It would be excellent for exception annotations to propagate through. |
|
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 |
|
No worries, and thanks for the work! I tried backporting this to But sadly GHC 9.10 seems noisier - your example code gives If you spot any obvious problem, let me know. |
My understanding of 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 ;)
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 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 |
|
Using GHC 9.12: Indeed, With no (So I'm wrong, throwing with However, throwing an exception caught with 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. |
|
With GHC 9.10.2: There is no difference between This is surprising. |
|
Haa, I think I got it. The problem is that in newest base, From
So the reason for the duplicate call stack is that it is part of the context AND part of 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. |
|
Also bear in mind that |
|
Seems reasonable. Could we have a test please? |
I'm doing that right now. |
|
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. 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 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 |
d374da2 to
d0f21f8
Compare
|
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 |
|
Thanks @guibou, looks good to me. In theory you should be able to use |
d0f21f8 to
c16d82d
Compare
|
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: Here I'm annoyed by:
I think the correct solution would be to add some sort of |
c16d82d to
3064e24
Compare
|
In the meantime, I've rebased the branch AND cleaned build with ghc 9.8 -> 9.14. |
fb09087 to
3d5cc30
Compare
|
@simonmar I've fixed the Regarding the fact that the "non-local" primitive do not appear in the callstack (e.g. For example, using the previous example, an exception will now be displayed with a callstack locating the exception original I've tested that it builds and run tests with GHC 9.8 -> 9.14. There are no tests for the new annotation for |
|
Just to say that I'm eagerly awaiting for this patch. I've been debugging some code with stack traces ending at rethrowing in |
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 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). |
|
@simonmar could you possibly take a look please? |
|
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. |
|
Just had another run in into a trace ending up at |
Control/Concurrent/Async/Internal.hs
Outdated
| var <- newEmptyTMVarIO | ||
| mask $ \restore -> do | ||
| let action_plus = debugLabelMe >> action | ||
| t <- doFork $ try (restore action_plus) >>= atomically . putTMVar var |
There was a problem hiding this comment.
Should not we use tryWithContext here? Otherwise I imagine we are losing stack traces from action.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I tried to explain the context in more details here: #165 (comment)
In summary:
- This
trystill catch the context and put it in theSomeExeptionwhich is "magically" recovered later using the uglyrethrowIO'logic. - This is done in order to limit the complexity of the refactor
There was a problem hiding this comment.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thank you for this suggestion. Please tell me is the latest commit I just pushed is what you were waiting for.
There was a problem hiding this comment.
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.
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.
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.
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.
|
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' :: |
There was a problem hiding this comment.
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 rto
concurrently' ::
CALLSTACK
IO a -> IO b
-> (IO (Either (ExceptionWithContext SomeException) (Either a b)) -> IO r)
-> IO ri.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.
There was a problem hiding this comment.
Also, catchAll in the body should probably be catchNoPropogate.
There was a problem hiding this comment.
@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?
There was a problem hiding this comment.
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.
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.
207f618 to
91a23d5
Compare
9d55314 to
5404d4d
Compare
|
New implementation approach pushed. Do you think we should include new functions, such as |
3012a47 to
b15a982
Compare
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.
fdd9100 to
c81b361
Compare
For easier usage of the Internal module in a backward compatible way in other libraries.
c81b361 to
bcf84de
Compare
|
I've added an entry in the |
This change is in order to have exception context propagation.
Everywhere we "re"-
throwexception withthrow, we instead userethrowwhich does not remove the exception context. I've introducedrethrowIO'which is just a backward compatibility wrapper overrethrowIOandthrowIO.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:
When run with this commit: