|
| 1 | +# Status |
| 2 | + |
| 3 | +📜 Proposed 2025-04-03 |
| 4 | + |
| 5 | +# Context |
| 6 | + |
| 7 | +This ADR enriches exceptions thrown from `IO` functions with call stacks up to the function usage site. |
| 8 | + |
| 9 | +## The problem |
| 10 | +This ADR bases on [[ADR-8-Use-RIO-in-cardano‐cli.md]]. |
| 11 | + |
| 12 | +The goal of this ADR is to provide better call stacks to `IO` exceptions thrown. |
| 13 | +The problem with exceptions thrown from IO monad is that they do not carry call stack from where they were thrown. |
| 14 | +This is not very convenient when for example doing multiple file access or network operations in a row. |
| 15 | +You won't know which one has thrown an exception: you will have only a vague exception message without many details, for example, just a message: |
| 16 | +``` |
| 17 | +Network.Socket.recvBuf: resource vanished (Connection reset by peer) |
| 18 | +``` |
| 19 | + |
| 20 | +## Solution |
| 21 | + |
| 22 | +The currently used type alias adds call stacks to the functions: |
| 23 | +```haskell |
| 24 | +type CIO e a = HasCallStack => CIO e a |
| 25 | +``` |
| 26 | +We can make sure that `IO` exceptions thrown in `IO` actions are captured with a helper `runIO` function, meant to be used instead of `liftIO`: |
| 27 | + |
| 28 | +```haskell |
| 29 | +import UnliftIO.Exceptions (catchAny, throwIO) |
| 30 | + |
| 31 | +runIO :: IO a -> CIO a |
| 32 | +runIO m = withFrozenCallStack $ catchAny (liftIO m) (throwIO . mkE) |
| 33 | + where |
| 34 | + mkE :: SomeException -> IoeWrapper |
| 35 | + mkE e = withFrozenCallStack $ IoeWrapper e |
| 36 | + |
| 37 | +-- | An exception wrapper type, adding a call stack to it |
| 38 | +data IoeWrapper = HasCallStack => IoeWrapper SomeException |
| 39 | + |
| 40 | +deriving instance Show IoeWrapper |
| 41 | + |
| 42 | +instance Exception IoeWrapper where |
| 43 | + displayException (IoeWrapper (SomeException e)) = |
| 44 | + constructorName <> ": " <> displayException e <> "\n" <> prettyCallStack callStack |
| 45 | + where |
| 46 | + constructorName = tyConName . typeRepTyCon $ typeOf e |
| 47 | +``` |
| 48 | +The provided `runIO` wraps synchronous exceptions into `IoeWrapper`, which adds additional information about call stack. |
| 49 | + |
| 50 | +The resulting call stack will contain entries up to the point where `runIO` was called. |
| 51 | +This means that in the following code: |
| 52 | +```haskell |
| 53 | +foo :: CIO e () |
| 54 | +foo = do |
| 55 | + someFunction |
| 56 | + runIO $ do |
| 57 | + someOtherFunction |
| 58 | + someExceptionThrowingFunction |
| 59 | +``` |
| 60 | +the exception thrown from `someExceptionThrowingFunction` and wrapped using `runIO` will only point to the place where `runIO` was called. |
| 61 | + |
| 62 | +# Decision |
| 63 | + |
| 64 | +TBD |
| 65 | + |
| 66 | +# Consequences |
| 67 | + |
| 68 | +1. More detailed call stacks when interacting with IO. |
| 69 | + |
| 70 | +1. **A need for manual step when writing new code**. |
| 71 | + A developer would have to remember to use `runIO` |
| 72 | + |
| 73 | +1. **Performance impact**. |
| 74 | + Including `HasCallStack` has [some performance penalty][hascallstack-perf-penalty], so it's may have some detrimental effect in loops executed many times. |
| 75 | + |
| 76 | + |
| 77 | + |
| 78 | +[hascallstack-perf-penalty]: https://stackoverflow.com/questions/57471398/how-does-hascallstack-influence-the-performance-of-a-normal-branch-in-haskell |
| 79 | + |
| 80 | +[modeline]: # ( vim: set spell spelllang=en: ) |
0 commit comments