Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Haskell HasCallStack unexpected behavior

The following is a simplification of a pretty common pattern we have, where you have some retry combinator wrapping an IO operation. I would like to have some stack traces so I added the HasCallStack constraint but the resulting stacktrace was not really satisfactory:

import Control.Monad (forM_)
import GHC.Stack

httpCall :: HasCallStack => IO ()
httpCall = do
  putStrLn $ prettyCallStack callStack
  print "http resolved"

retry :: HasCallStack => IO () -> IO ()
retry op =
  forM_ [1] $ \i ->
    op

main :: IO ()
main = retry httpCall

stacktrace:

CallStack (from HasCallStack):
  httpCall, called at main.hs:16:14 in main:Main
"http resolved"

I assumed the HasCallStack constraint gets resolved in main to fit the argument type of retry so I added the constraint to the argument type:

{-# LANGUAGE RankNTypes #-}

import Control.Monad (forM_)
import GHC.Stack

httpCall :: HasCallStack => IO ()
httpCall = do
  putStrLn $ prettyCallStack callStack
  print "http resolved"

retry :: HasCallStack => (HasCallStack => IO()) -> IO () -- NOTICE the constraint in the argument
retry op =
  forM_ [1] $ \i ->
    op

main :: IO ()
main = retry httpCall

Now the stacktrace has 2 more entries both of them quite surprising:

CallStack (from HasCallStack):
  httpCall, called at main.hs:17:14 in main:Main
  op, called at main.hs:14:5 in main:Main
  retry, called at main.hs:17:8 in main:Main
"http resolved"

Problems

  1. httpCall reports it was called from main (line 17)
  2. op reports the correct line but is quite unexpected to see it in the stacktrace to begin with.

I expected something along the lines of:

CallStack (from HasCallStack):
  httpCall, called at main.hs:14:5 in main:Main
  retry, called at main.hs:17:8 in main:Main
"http resolved"
like image 614
fghibellini Avatar asked Nov 16 '22 21:11

fghibellini


1 Answers

For both problems 1 and 2, note that HasCallStack call stacks are fundamentally "lexical".

Specifically, with respect to "problem 1", the call site (function name and line number) information in the call stack is based on where syntactically in the program the called function appears, not the line of code that actually forces the call to be made. So, the reason httpCall is shown to be called from line 17 and not line 14 is because the literal program text "httpCall" appears on line 17. Similarly for "problem 2", the thing called on line 14 appears lexically as op, not httpCall, so that's how it appears in the call stack.

With respect to the behavior of your programs:

In your first program, the HasCallStack constraints on retry and httpCall in main are resolved by pushing appropriate call entries (with lexical call site information) onto an empty call stack (because main provides no HasCallStack instance itself). If we made the call stack argument explicit, the code would look something like:

httpCall :: CallStack -> IO ()
httpCall callStack = do
  putStrLn $ prettyCallStack callStack
  print "http resolved"

retry :: CallStack -> IO () -> IO ()
retry callStack op =
  forM_ [1] $ \i ->
    op

main :: IO ()
main = retry (push1 emptyCallStack) (httpCall (push2 emptyCallStack))
  where push1 = pushCallStack ("retry", SrcLoc "" "" "" 15 7 15 11)
        push2 = pushCallStack ("httpCall", SrcLoc "" "" "" 15 13 15 20)

Note that the callStack argument to retry that records the retry call (push1) isn't actually used. When httpCall is evaluated, it gets passed push2 emptyCallStack which generates a single entry for the httpCall call in main.

I think all that is more or less what you assumed was happening (i.e., the HasCallStack constraint gets resolved in main to fit the argument type of retry).

In your second program, something interesting happens that perhaps isn't well documented. The retry call is treated the same way as before, but the httpCall -- because it is typed as HasCallStack => IO () instead of IO () -- produces code that expects an instance to be in scope instead of resolving the instance immediately. With explicit call stack arguments, the code generated for retry and main would now look something like:

retry :: CallStack -> (CallStack -> IO ()) -> IO ()
retry callStack op =
  forM_ [1] $ \i ->
    op (push3 callStack)
  where push3 = pushCallStack ("op", SrcLoc "" "" "" 14 4 14 6)

main :: IO ()
main = retry (push1 emptyCallStack) (\callStack -> httpCall (push2 callStack))
  where push1 = pushCallStack ("retry", SrcLoc "" "" "" 17 7 17 11)
        push2 = pushCallStack ("httpCall", SrcLoc "" "" "" 17 13 17 20)

The result is that httpCall actually gets push2 $ push3 $ push1 emptyCallStack as its argument, so you see the three entries for httpCall, op, and retry in that order.

like image 198
K. A. Buhr Avatar answered Dec 15 '22 02:12

K. A. Buhr