{- HLINT ignore "Use head" -}
{- HLINT ignore "Use void" -}
module Main (main) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar)
import Control.Exception (SomeException, catch, evaluate)
import Control.Monad (replicateM_)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Data.UUID.V4 qualified as UUID
import GHC.Exts.Heap (Box (..), GenClosure (..), asBox, getClosureData)
import Hypermedia.Datastar
import Network.HTTP.Types (queryToQueryText, status200, status400, status404)
import Network.Wai (Application, Request, Response, pathInfo, queryString, requestMethod, responseLBS)
import Network.Wai.Handler.Warp qualified as Warp
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
import System.Mem (performGC)
-- Per-user session state
data Session = Session
{ sessId :: UUID
, sessExpression :: TVar Box
, sessExprDesc :: TVar Text
, sessBoxMap :: TVar (Map Text Box)
}
-- Shared application state
data AppState = AppState
{ appSessions :: TVar (Map UUID Session)
, appMode :: Mode
, appHasRun :: Bool
}
-- Mode configuration
data Mode = Mode
{ modeName :: Text
, modeDesc :: Text
, modeSetup :: Session -> IO ()
, modeRun :: Maybe (Session -> IO ())
}
-- Expression constructors - NOINLINE + () argument ensures each call
-- allocates fresh thunks at -O0 (which heap-view uses). The () forces
-- function entry, and at -O0 GHC doesn't float subexpressions out as CAFs.
-- An IO [Int] with `pure $` does NOT work: the thunk is part of the CAF
-- and shared across all calls, so forced thunks stay forced after reset.
mkSimpleExpr :: () -> [Int]
mkSimpleExpr () = [1, 2, 3, 4, 5] ++ map (* 10) [6, 7, 8]
{-# NOINLINE mkSimpleExpr #-}
mkMapExpr :: () -> [Int]
mkMapExpr () = map (* 2) [1 .. 10]
{-# NOINLINE mkMapExpr #-}
mkFibsExpr :: () -> [Int]
mkFibsExpr () = take 15 fibs
where
fibs :: [Int]
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
{-# NOINLINE mkFibsExpr #-}
-- Available modes
simpleList :: Mode
simpleList =
Mode
{ modeName = "simple-list"
, modeDesc = "[1,2,3,4,5] ++ map (*10) [6,7,8]"
, modeSetup = \sess -> do
let expr = mkSimpleExpr ()
_ <- evaluate expr
atomically $ do
writeTVar (sessExpression sess) (asBox expr)
writeTVar (sessExprDesc sess) "[1,2,3,4,5] ++ map (*10) [6,7,8]"
, modeRun = Nothing
}
liveMap :: Mode
liveMap =
Mode
{ modeName = "live-map"
, modeDesc = "map (*2) [1..10]"
, modeSetup = \sess -> do
let expr = mkMapExpr ()
atomically $ do
writeTVar (sessExpression sess) (asBox expr)
writeTVar (sessExprDesc sess) "map (*2) [1..10]"
, modeRun = Just $ \sess -> do
let expr = mkMapExpr ()
atomically $ do
writeTVar (sessExpression sess) (asBox expr)
writeTVar (sessExprDesc sess) "map (*2) [1..10]"
_ <- forkIO $ forceListSlowly expr
pure ()
}
liveFibs :: Mode
liveFibs =
Mode
{ modeName = "live-fibs"
, modeDesc = "take 15 fibs"
, modeSetup = \sess -> do
let expr = mkFibsExpr ()
atomically $ do
writeTVar (sessExpression sess) (asBox expr)
writeTVar (sessExprDesc sess) "take 15 fibs"
, modeRun = Just $ \sess -> do
let expr = mkFibsExpr ()
atomically $ do
writeTVar (sessExpression sess) (asBox expr)
writeTVar (sessExprDesc sess) "take 15 fibs"
_ <- forkIO $ forceListSlowly expr
pure ()
}
allModes :: [(String, Mode)]
allModes =
[ ("simple-list", simpleList)
, ("live-map", liveMap)
, ("live-fibs", liveFibs)
]
-- Session management
newSession :: AppState -> IO Session
newSession appState = do
sid <- UUID.nextRandom
sess <-
Session sid
<$> newTVarIO (asBox ())
<*> newTVarIO ""
<*> newTVarIO Map.empty
atomically $ modifyTVar' (appSessions appState) (Map.insert sid sess)
pure sess
lookupSession :: AppState -> UUID -> IO (Maybe Session)
lookupSession appState sid =
Map.lookup sid <$> readTVarIO (appSessions appState)
getSessionId :: Request -> Maybe UUID
getSessionId req =
case lookup "s" (queryToQueryText (queryString req)) of
Just (Just s) -> UUID.fromText s
_ -> Nothing
withSession :: AppState -> Request -> (Response -> IO b) -> (Session -> IO b) -> IO b
withSession appState req respond action =
case getSessionId req of
Just sid -> do
msess <- lookupSession appState sid
case msess of
Just sess -> action sess
Nothing -> respond $ responseLBS status404 [] "Session not found"
Nothing -> respond $ responseLBS status400 [] "Missing session"
-- Force a list spine + elements one by one with delay
forceListSlowly :: [Int] -> IO ()
forceListSlowly [] = pure ()
forceListSlowly (x : xs) = do
_ <- evaluate x
threadDelay 1000000
forceListSlowly xs
-- Heap node representation
data HeapNode = HeapNode
{ nodeType :: Text
, nodeValue :: Maybe Text
, nodePointers :: [Text]
}
-- Get closure data from a Box (unwrap the Box to see what's inside)
getBoxClosure :: Box -> IO (GenClosure Box)
getBoxClosure (Box a) = getClosureData a
boxAddr :: Box -> Text
boxAddr = T.pack . show
-- Walk the heap from a root Box via DFS
walkHeap :: Session -> Box -> Int -> IO (Text, Map Text HeapNode)
walkHeap sess startBox maxDepth = do
nodesRef <- newTVarIO Map.empty
boxMapRef <- newTVarIO Map.empty
visitedRef <- newTVarIO Set.empty
walkNode nodesRef boxMapRef visitedRef startBox 0
nodes <- readTVarIO nodesRef
boxMap <- readTVarIO boxMapRef
atomically $ writeTVar (sessBoxMap sess) boxMap
pure (boxAddr startBox, nodes)
where
walkNode nodesRef boxMapRef visitedRef box depth
| depth > maxDepth = pure ()
| otherwise = do
let addr = boxAddr box
visited <- readTVarIO visitedRef
if Set.member addr visited
then pure ()
else do
atomically $ do
modifyTVar' visitedRef (Set.insert addr)
modifyTVar' boxMapRef (Map.insert addr box)
closure <- getBoxClosure box
-- Follow indirections transparently
closure' <- case closure of
IndClosure{indirectee = ptr} -> getBoxClosure ptr
BlackholeClosure{indirectee = ptr} -> getBoxClosure ptr
_ -> pure closure
case closure' of
ConstrClosure{ptrArgs = ptrs, dataArgs = dargs, name = n, modl = m} -> do
let fullName = T.pack m <> "." <> T.pack n
ptrAddrs = map boxAddr ptrs
if length ptrs >= 2 && T.pack n == ":"
then do
-- Cons cell: first ptr is head, second is tail
let headBox = ptrs !! 0
tailBox = ptrs !! 1
headVal <- getHeadValue headBox
let node = HeapNode "cons" headVal [boxAddr tailBox]
atomically $ modifyTVar' nodesRef (Map.insert addr node)
walkNode nodesRef boxMapRef visitedRef headBox (depth + 1)
walkNode nodesRef boxMapRef visitedRef tailBox (depth + 1)
else
if null ptrs && (T.pack n == "[]")
then do
let node = HeapNode "nil" Nothing []
atomically $ modifyTVar' nodesRef (Map.insert addr node)
else do
let val = case dargs of
[] -> Nothing
[v] -> Just (T.pack (show v))
vs -> Just (T.pack (show vs))
let node = HeapNode ("constr:" <> fullName) val ptrAddrs
atomically $ modifyTVar' nodesRef (Map.insert addr node)
mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
ThunkClosure{ptrArgs = ptrs} -> do
let ptrAddrs = map boxAddr ptrs
node = HeapNode "thunk" Nothing ptrAddrs
atomically $ modifyTVar' nodesRef (Map.insert addr node)
mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
FunClosure{ptrArgs = ptrs} -> do
let ptrAddrs = map boxAddr ptrs
node = HeapNode "function" Nothing ptrAddrs
atomically $ modifyTVar' nodesRef (Map.insert addr node)
mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
PAPClosure{payload = ptrs} -> do
let ptrAddrs = map boxAddr ptrs
node = HeapNode "pap" Nothing ptrAddrs
atomically $ modifyTVar' nodesRef (Map.insert addr node)
mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
APClosure{payload = ptrs} -> do
let ptrAddrs = map boxAddr ptrs
node = HeapNode "ap" Nothing ptrAddrs
atomically $ modifyTVar' nodesRef (Map.insert addr node)
mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
SelectorClosure{selectee = ptr} -> do
let node = HeapNode "selector" Nothing [boxAddr ptr]
atomically $ modifyTVar' nodesRef (Map.insert addr node)
walkNode nodesRef boxMapRef visitedRef ptr (depth + 1)
MutVarClosure{var = ptr} -> do
let node = HeapNode "mutvar" Nothing [boxAddr ptr]
atomically $ modifyTVar' nodesRef (Map.insert addr node)
walkNode nodesRef boxMapRef visitedRef ptr (depth + 1)
_ -> do
let node = HeapNode "other" (Just (T.pack (take 80 (show closure')))) []
atomically $ modifyTVar' nodesRef (Map.insert addr node)
-- Try to extract a displayable value from a cons head pointer
getHeadValue :: Box -> IO (Maybe Text)
getHeadValue box = do
c <- getBoxClosure box
pure $ case c of
ConstrClosure{dataArgs = (v : _), name = n}
| n == "I#" -> Just (T.pack (show v))
| n == "W#" -> Just (T.pack (show v))
| n == "C#" -> Just (T.pack (show (toEnum (fromIntegral v) :: Char)))
| otherwise -> Just (T.pack n <> " " <> T.pack (show v))
_ -> Nothing
-- Force a thunk by its Box address
forceThunk :: Session -> Text -> IO ()
forceThunk sess addr = do
boxMap <- readTVarIO (sessBoxMap sess)
case Map.lookup addr boxMap of
Nothing -> putStrLn $ "Box not found: " <> T.unpack addr
Just (Box a) -> do
_ <-
(evaluate a >> pure ())
`catch` \(e :: SomeException) ->
putStrLn $ "Exception while forcing: " <> show e
pure ()
-- Render the heap as an HTML table
renderHeapTable :: UUID -> Bool -> Text -> Text -> Map Text HeapNode -> Text
renderHeapTable sid showRunBtn exprDesc rootAddr nodes =
"
"
<> "
"
<> "
GHC Heap Visualizer
"
<> "
"
<> runButton
<> ""
<> ""
<> ""
<> "
"
<> "
Expression: "
<> exprDesc
<> ""
<> " · Root: "
<> cleanAddr rootAddr
<> ""
<> " · "
<> T.pack (show (Map.size nodes))
<> " nodes
"
<> "
"
<> "
"
<> ""
<> "| Address | "
<> "Type | "
<> "Value | "
<> "Pointers | "
<> "Actions | "
<> "
"
<> mconcat (map renderRow orderedNodes)
<> "
"
where
s = UUID.toText sid
runButton
| showRunBtn =
""
| otherwise = ""
-- Root first, then the rest sorted by address
orderedNodes =
let rootNode = case Map.lookup rootAddr nodes of
Just n -> [(rootAddr, n)]
Nothing -> []
rest = Map.toAscList (Map.delete rootAddr nodes)
in rootNode <> rest
renderRow (addr, node) =
let isRoot = addr == rootAddr
ca = cleanAddr addr
rowClass =
if isRoot
then "bg-blue-50 dark:bg-gray-800/50 border-l-2 border-blue-500"
else "border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-50 dark:hover:bg-gray-800/30"
highlight =
" data-class=\"{"
<> "'ring-2 ring-inset ring-cyan-400 dark:ring-cyan-500 bg-cyan-50 dark:bg-cyan-900/30': $highlight === '"
<> ca
<> "'"
<> "}\""
in " highlight
<> ">"
<> "| "
<> ca
<> (if isRoot then " (root)" else "")
<> " | "
<> ""
<> typeBadge (nodeType node)
<> " | "
<> ""
<> maybe "-" (\v -> "" <> v <> "") (nodeValue node)
<> " | "
<> ""
<> renderPointers (nodePointers node)
<> " | "
<> ""
<> renderActions addr node
<> " | "
<> "
"
cleanAddr addr = T.replace "/1" "" (T.replace "/2" "" addr)
typeBadge ty
| ty == "cons" = badge "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" ":"
| ty == "nil" = badge "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" "[]"
| ty == "thunk" = badge "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300" "thunk"
| ty == "function" = badge "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300" "fun"
| ty == "pap" = badge "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300" "PAP"
| ty == "ap" = badge "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300" "AP"
| ty == "selector" = badge "bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300" "sel"
| ty == "other" = badge "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" "other"
| "constr:GHC.Types.I#" `T.isPrefixOf` ty = badge "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" "I#"
| "constr:" `T.isPrefixOf` ty = badge "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300" (T.drop 7 ty)
| otherwise = badge "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" ty
badge cls label =
""
<> label
<> ""
renderPointers [] = "-"
renderPointers ptrs = T.intercalate " " (map renderPtr ptrs)
renderPtr p =
let cp = cleanAddr p
in " " data-on:mouseenter=\"$highlight = '"
<> cp
<> "'\""
<> " data-on:mouseleave=\"$highlight = ''\""
<> " class='text-cyan-700 dark:text-cyan-600 hover:underline cursor-pointer'>"
<> cp
<> ""
renderActions addr node
| nodeType node == "thunk" || nodeType node == "ap" || nodeType node == "selector" =
""
| otherwise = ""
-- Application
main :: IO ()
main = do
args <- getArgs
case args of
[name] | Just mode <- lookup name allModes -> startServer 3000 mode
[name, portStr] | Just mode <- lookup name allModes -> startServer (read portStr) mode
_ -> do
hPutStrLn stderr "Usage: heap-view [port]"
hPutStrLn stderr ""
hPutStrLn stderr "Modes:"
mapM_ (\(name, mode) -> hPutStrLn stderr $ " " <> name <> replicate (16 - length name) ' ' <> T.unpack (modeDesc mode)) allModes
exitFailure
startServer :: Int -> Mode -> IO ()
startServer port mode = do
htmlContent <- BS.readFile "examples/heap-view.html"
let hasRun = case modeRun mode of Just _ -> True; Nothing -> False
appState <-
AppState
<$> newTVarIO Map.empty
<*> pure mode
<*> pure hasRun
putStrLn $ "GHC Heap Visualizer [" <> T.unpack (modeName mode) <> "]"
putStrLn $ "Listening on http://localhost:" <> show port
Warp.run port (app htmlContent appState)
app :: BS.ByteString -> AppState -> Application
app htmlContent appState req respond =
case (requestMethod req, pathInfo req) of
("GET", []) ->
respond $ responseLBS status200 [("Content-Type", "text/html")] (LBS.fromStrict htmlContent)
("GET", ["heap"]) ->
withSession appState req respond $ \sess ->
handleHeap appState sess respond
("GET", ["force"]) ->
withSession appState req respond $ \sess ->
handleForce appState sess req respond
("GET", ["run"])
| Just _ <- modeRun (appMode appState) ->
withSession appState req respond $ \sess ->
handleRun appState sess respond
("GET", ["reset"]) ->
handleReset appState req respond
_ ->
respond $ responseLBS status404 [] "Not found"
sendHeapUpdate :: AppState -> Session -> ServerSentEventGenerator -> IO ()
sendHeapUpdate appState sess gen = do
box <- readTVarIO (sessExpression sess)
desc <- readTVarIO (sessExprDesc sess)
performGC
(rootAddr, nodes) <- walkHeap sess box 20
let html = renderHeapTable (sessId sess) (appHasRun appState) desc rootAddr nodes
sendPatchElements gen (patchElements html)
handleHeap :: AppState -> Session -> (Response -> IO b) -> IO b
handleHeap appState sess respond =
respond $ sseResponse nullLogger $ \gen ->
sendHeapUpdate appState sess gen
handleForce :: AppState -> Session -> Request -> (Response -> IO b) -> IO b
handleForce appState sess req respond = do
let params = queryToQueryText (queryString req)
case lookup "addr" params of
Just (Just addr) -> do
forceThunk sess addr
respond $ sseResponse nullLogger $ \gen ->
sendHeapUpdate appState sess gen
_ ->
respond $ responseLBS status400 [] "Missing addr parameter"
handleReset :: AppState -> Request -> (Response -> IO b) -> IO b
handleReset appState req respond = do
-- Reuse existing session on explicit reset, create new on first page load
sess <- case getSessionId req of
Just sid -> do
msess <- lookupSession appState sid
case msess of
Just s -> pure s
Nothing -> newSession appState
Nothing -> newSession appState
modeSetup (appMode appState) sess
respond $ sseResponse nullLogger $ \gen ->
sendHeapUpdate appState sess gen
handleRun :: AppState -> Session -> (Response -> IO b) -> IO b
handleRun appState sess respond = do
case modeRun (appMode appState) of
Just run -> do
run sess
respond $ sseResponse nullLogger $ \gen -> do
-- Stream live updates every 200ms for ~12 seconds
replicateM_ 60 $ do
sendHeapUpdate appState sess gen
threadDelay 200000
Nothing ->
respond $ responseLBS status404 [] "Not found"