Skip to content

Commit 85219b0

Browse files
committed
Add ADR: better call stacks of io exceptions
1 parent 1ff0dd8 commit 85219b0

File tree

1 file changed

+80
-0
lines changed

1 file changed

+80
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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

Comments
 (0)