packages feed

ghc-stack-profiler-core-0.5.0.0: src/GHC/Stack/Profiler/Core/Internal/Eventlog.hs

{-# LANGUAGE MultiWayIf #-}

module GHC.Stack.Profiler.Core.Internal.Eventlog (
  -- * Eventlog Message types
  Message (..),
  ProtocolVersion (MkProtocolVersion, MyProtocolVersion),
  ProtocolVersionMismatch (..),
  CallStackChunk (..),
  StringDef (..),
  SourceLocationDef (..),
  CallStackFrame (..),
  ThreadId (..),
  CapabilityId (..),
  StringId (..),
  nextStringId,
  SourceLocationId (..),
  nextSourceLocationId,
  IpeId (..),
  deserializeEventlogMessage,
  joinCallStackChunks,

  -- * Low-level API
  MessageTag (..),
  messageTagSize,
  CallStackFrameTag (..),
  ipeIdSize,
  stringIdSize,
  sourceLocationIdSize,
  messageMaxSize,
  messageMinSize,
  stringDefBodyMaxSize,
  sourceLocationDefSize,
  ShortText (..),
  toShortText,
  truncateTextToByteLimit,
  callStackFrameTagSize,
  callStackMaxLen,
  callStackMaxLen',
  callStackFrameSize,
  callStackFrameMaxSize,
  capabilityIdSize,
  threadIdSize,
) where

import Control.Exception (Exception (..), assert, throw)
import Control.Monad (replicateM, when)
import Data.Binary
import Data.Binary.Get (getByteString, runGetOrFail)
import Data.Binary.Put (putByteString)
import qualified Data.ByteString.Lazy as LBS
import Data.Coerce (coerce)
import qualified Data.List as List
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as NonEmpty
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Foreign as TF
import GHC.Generics
import GHC.Stack.Profiler.Core.Internal.Util
import Text.Printf (printf)

-- ----------------------------------------------------------------------------
-- Eventlog Messages
-- ----------------------------------------------------------------------------

-- | Efficient serialisation format of the GHC RTS callstack.
--
-- Message format:
--
-- @
-- 'Message'
--  := FF CA (stackFinal: 'CallStackChunk')
--   | FF CB (stackChunk: 'CallStackChunk')
--   | FF CC (stringDef: 'StringDef')
--   | FF CD (sourceLocationDef: 'SourceLocationDef')
--
-- 'CallStackChunk'
--  := (capabilityId: 'Word32') (threadId: 'Word32') (callStackLen: 'Word16') (callStack: 'CallStackFrame'{callStackLen})
--  -- NOTE: callStackLen must be smaller than (2^16 - 8) / 9
--
-- 'CallStackFrame'
--  := 01 (ipe: 'Word64')
--   | 02 (stringId: 'Word64')
--   | 03 (stringId: 'Word64') (sourceLocationId: 'Word64')
--
-- 'StringDef'
--  := (stringId: 'Word64') (stringLen: 'Word16') (string: 'Char'{stringLen})
--  -- NOTE: stringLen must be smaller than 2^16 - 8
--
-- 'SourceLocationDef'
--  := (sourceLocationId: 'Word64') (row: 'Word32') (column: 'Word32') (functionId: 'Word64') (filename: 'Word64')
-- @
data Message
  = -- | The version of the protocol.
    ProtocolVersion !ProtocolVersion
  | -- | A chunk of the call-stack, indicated by the prefix @FF CA@.
    --
    --   This variant indicates that no further 'CallStackChunk' or 'CallStackFinal' will follow.
    CallStackFinal !CallStackChunk
  | -- | A chunk of the call-stack, indicated by the prefix @FF CB@.
    --
    --   This variant indicates that another 'CallStackChunk' or 'CallStackFinal' will follow.
    CallStackChunk !CallStackChunk
  | -- | A string definition, indicated by the prefix @FF CC@.
    --
    --   This messages associates the string ID @stringId@ with the string
    --   @strLen@, for future use in call-stack messages and source location
    --   definitions.
    StringDef !StringDef
  | -- | A source location definition, indicated by the prefix @FF CD@.
    --
    --   This message associates the source location ID @srcLocId@ with the
    --   source location specified by @row@, @col@, @functionId@, and
    --   @filename@, for future use in call-stack messages.
    SourceLocationDef !SourceLocationDef
  deriving (Eq, Ord, Show, Read, Generic)

-- | The version of the protocol implemented by the `Message` type.
newtype ProtocolVersion
  = MkProtocolVersion {getProtocolVersion :: Word8}
  deriving (Eq, Ord, Show, Read, Generic)
  deriving newtype (Binary)

-- | The version of the protocol implemented by this package.
--
--   __Note:__ This should always match the super-major version number of the
--             @ghc-stack-profiler-core@ package. If the package version is
--             @A.B.C.D@, the protocol version is @A@.
pattern MyProtocolVersion :: ProtocolVersion
pattern MyProtocolVersion = MkProtocolVersion 0

data ProtocolVersionMismatch
  = MkProtocolVersionMismatch
  { expectProtocolVersion :: !ProtocolVersion
  , actualProtocolVersion :: !ProtocolVersion
  }
  deriving (Eq, Ord, Show, Read, Generic)

instance Exception ProtocolVersionMismatch where
  displayException :: ProtocolVersionMismatch -> String
  displayException e =
    let
      expect = getProtocolVersion (expectProtocolVersion e)
      actual = getProtocolVersion (actualProtocolVersion e)
    in
      concat
        [ "The protocol version of the input ("
        , show actual
        , ") does not match the version implemented by this package ("
        , show expect
        , ")."
        ]

data CallStackChunk = MkCallStackChunk
  { callStackChunkThreadId :: !ThreadId
  , callStackChunkCapabilityId :: !CapabilityId
  , callStackChunk :: ![CallStackFrame]
  }
  deriving (Eq, Ord, Show, Read, Generic)

data StringDef = MkStringDef
  { stringDefId :: !StringId
  , stringDefBody :: !Text
  }
  deriving (Eq, Ord, Show, Read, Generic)

data SourceLocationDef = MkSourceLocationDef
  { sourceLocationDefId :: {-# UNPACK #-} !SourceLocationId
  , sourceLocationDefRow :: {-# UNPACK #-} !Word32
  , sourceLocationDefColumn :: {-# UNPACK #-} !Word32
  , sourceLocationDefFilename :: {-# UNPACK #-} !StringId
  }
  deriving (Eq, Ord, Show, Read, Generic)

data CallStackFrame
  = CallStackFrameIpe {-# UNPACK #-} !IpeId
  | CallStackFrameAnn {-# UNPACK #-} !StringId {-# UNPACK #-} !(Maybe SourceLocationId)
  deriving (Eq, Ord, Show, Read, Generic)

-- | The ID of a thread.
newtype ThreadId
  = MkThreadId
  { getThreadId :: Word64
  }
  deriving (Show, Eq, Ord, Read, Generic)
  deriving newtype (Binary)

-- | The ID of a capability.
newtype CapabilityId
  = MkCapabilityId
  { getCapabilityId :: Word32
  }
  deriving (Show, Eq, Ord, Read, Generic)
  deriving newtype (Binary)

newtype StringId = MkStringId
  { getStringId :: Word64
  }
  deriving (Eq, Ord, Show, Read, Generic)

nextStringId :: StringId -> StringId
nextStringId (MkStringId sid) = MkStringId (sid + 1)

newtype SourceLocationId = MkSourceLocationId
  { getSourceLocationId :: Word64
  }
  deriving (Eq, Ord, Show, Read, Generic)

nextSourceLocationId :: SourceLocationId -> SourceLocationId
nextSourceLocationId (MkSourceLocationId slId) = MkSourceLocationId (slId + 1)

newtype IpeId = MkIpeId
  { getIpeId :: Word64
  }
  deriving (Eq, Ord, Show, Read, Generic)

-- | Deserialise a `Message`.
--
--   __Warning:__ This function may throw `ProtocolVersionMismatch`.
deserializeEventlogMessage :: LBS.ByteString -> Either String Message
deserializeEventlogMessage msg = case runGetOrFail get msg of
  Left (_, _, errMsg) -> Left errMsg
  Right (_, _, callStackMessage) -> Right callStackMessage

-- | Combine all 'CallStackChunk's into a single 'CallStackChunk'.
-- We assume that all 'CallStackChunk' only differ in their 'callStackChunk' values.
--
-- 'joinCallStackChunks' is the conceptually inverse of 'chunkCallStack'.
joinCallStackChunks :: NonEmpty CallStackChunk -> CallStackChunk
joinCallStackChunks msgs =
  MkCallStackChunk
    { callStackChunkThreadId = callStackChunkThreadId $ NonEmpty.head msgs
    , callStackChunkCapabilityId = callStackChunkCapabilityId $ NonEmpty.head msgs
    , callStackChunk = concatMap (reverse . callStackChunk) . reverse $ NonEmpty.toList msgs
    }

-------------------------------------------------------------------------------
-- Binary instances
-------------------------------------------------------------------------------

-------------------------------------------------------------------------------
-- Message Tags

data MessageTag
  = ProtocolVersionTag
  | CallStackFinalTag
  | CallStackChunkTag
  | StringDefTag
  | SourceLocationDefTag
  deriving (Bounded, Enum, Eq, Show)

messageTagSize :: Int
messageTagSize = 2

messageTagToWord16 :: MessageTag -> Word16
messageTagToWord16 = \case
  ProtocolVersionTag -> 0xFFC0
  CallStackFinalTag -> 0xFFCA
  CallStackChunkTag -> 0xFFCB
  StringDefTag -> 0xFFCC
  SourceLocationDefTag -> 0xFFCD

instance Binary MessageTag where
  put :: MessageTag -> Put
  put = putWord16 . messageTagToWord16

  get :: Get MessageTag
  get =
    getWord16 >>= \case
      0xFFC0 -> pure ProtocolVersionTag
      0xFFCA -> pure CallStackFinalTag
      0xFFCB -> pure CallStackChunkTag
      0xFFCC -> pure StringDefTag
      0xFFCD -> pure SourceLocationDefTag
      badTag ->
        fail $
          printf
            "Found invalid message tag %s. Expected one of %s."
            (showAsHex badTag)
            (List.intercalate ", " messageTags)
   where
    messageTags :: [String]
    messageTags = [showAsHex (messageTagToWord16 tag) | tag <- [minBound .. maxBound]]

-------------------------------------------------------------------------------
-- Messages

-- | __Warning:__ `get` may throw `ProtocolVersionMismatch`.
instance Binary Message where
  put :: Message -> Put
  put = \case
    ProtocolVersion protocolVersion -> do
      put ProtocolVersionTag
      put protocolVersion
    CallStackFinal callStackChunk -> do
      put CallStackFinalTag
      put callStackChunk
    CallStackChunk callStackChunk -> do
      put CallStackChunkTag
      put callStackChunk
    StringDef stringDef -> do
      put StringDefTag
      put stringDef
    SourceLocationDef sourceLocationDef -> do
      put SourceLocationDefTag
      put sourceLocationDef

  get :: Get Message
  get =
    get >>= \case
      ProtocolVersionTag -> do
        protocolVersion <- get
        when (protocolVersion /= MyProtocolVersion) $
          throw $
            MkProtocolVersionMismatch MyProtocolVersion protocolVersion
        pure $ ProtocolVersion protocolVersion
      CallStackFinalTag ->
        CallStackFinal <$> get
      CallStackChunkTag ->
        CallStackChunk <$> get
      StringDefTag ->
        StringDef <$> get
      SourceLocationDefTag ->
        SourceLocationDef <$> get

messageMaxSize :: Int
messageMaxSize =
  fromIntegral (maxBound @Word16)

messageMinSize :: Int
messageMinSize =
  messageTagSize
    + minimum
      [ {- CallStackChunk/CallStackFinal -}
        capabilityIdSize + threadIdSize + callStackLenSize
      , {- StringDef -}
        stringIdSize + stringDefBodyLenSize
      , {- SourceLocationDef -}
        sourceLocationDefSize
      ]

-------------------------------------------------------------------------------
-- CallStackChunks

instance Binary CallStackChunk where
  put :: CallStackChunk -> Put
  put MkCallStackChunk{callStackChunkCapabilityId, callStackChunkThreadId, callStackChunk} = do
    put callStackChunkCapabilityId
    put callStackChunkThreadId
    let
      callStackChunkLength = length callStackChunk
    putWord16 $ fromIntegral callStackChunkLength
    mapM_ put callStackChunk

  get :: Get CallStackChunk
  get = do
    callStackChunkCapabilityId <- get
    callStackChunkThreadId <- get
    callStackChunkLength <- fromIntegral <$> getWord16
    callStackChunk <- replicateM callStackChunkLength get
    pure MkCallStackChunk{callStackChunkThreadId, callStackChunkCapabilityId, callStackChunk}

-------------------------------------------------------------------------------
-- CallStackFrameTags

data CallStackFrameTag
  = CallStackFrameIpeTag
  | CallStackFrameAnnWithNothingTag
  | CallStackFrameAnnWithJustSourceLocationTag
  deriving (Bounded, Enum, Eq, Show)

callStackFrameTagSize :: Int
callStackFrameTagSize = 1

callStackFrameTagToWord8 :: CallStackFrameTag -> Word8
callStackFrameTagToWord8 = \case
  CallStackFrameIpeTag -> 0x1
  CallStackFrameAnnWithNothingTag -> 0x2
  CallStackFrameAnnWithJustSourceLocationTag -> 0x3

instance Binary CallStackFrameTag where
  put :: CallStackFrameTag -> Put
  put = putWord8 . callStackFrameTagToWord8

  get :: Get CallStackFrameTag
  get =
    getWord8 >>= \case
      0x1 -> pure CallStackFrameIpeTag
      0x2 -> pure CallStackFrameAnnWithNothingTag
      0x3 -> pure CallStackFrameAnnWithJustSourceLocationTag
      badTag ->
        fail $
          printf
            "Found invalid call-stack frame tag %s. Expected one of %s."
            (showAsHex badTag)
            (List.intercalate ", " callStackFrameTags)
   where
    callStackFrameTags :: [String]
    callStackFrameTags = [showAsHex (callStackFrameTagToWord8 tag) | tag <- [minBound .. maxBound]]

-------------------------------------------------------------------------------
-- CallStackFrames

instance Binary IpeId where
  put :: IpeId -> Put
  put = putWord64 . coerce

  get :: Get IpeId
  get = coerce getWord64

ipeIdSize :: Int
ipeIdSize = 8

instance Binary CallStackFrame where
  put :: CallStackFrame -> Put
  put = \case
    CallStackFrameIpe ipeId -> do
      put CallStackFrameIpeTag
      put ipeId
    CallStackFrameAnn stringId Nothing -> do
      put CallStackFrameAnnWithNothingTag
      put stringId
    CallStackFrameAnn stringId (Just sourceLocationId) -> do
      put CallStackFrameAnnWithJustSourceLocationTag
      put stringId
      put sourceLocationId

  get :: Get CallStackFrame
  get = do
    get >>= \case
      CallStackFrameIpeTag ->
        CallStackFrameIpe <$> get
      CallStackFrameAnnWithNothingTag ->
        CallStackFrameAnn <$> get <*> pure Nothing
      CallStackFrameAnnWithJustSourceLocationTag ->
        CallStackFrameAnn <$> get <*> (Just <$> get)

-------------------------------------------------------------------------------
-- StringDefs

instance Binary StringId where
  put :: StringId -> Put
  put = putWord64 . coerce

  get :: Get StringId
  get = coerce getWord64

stringIdSize :: Int
stringIdSize = 8

instance Binary StringDef where
  put :: StringDef -> Put
  put MkStringDef{stringDefId, stringDefBody} = do
    put stringDefId
    put $ MkUnsafeShortText (truncateTextToByteLimit stringDefBodyMaxSize stringDefBody)

  get :: Get StringDef
  get = do
    stringDefId <- get
    -- NOTE: This allows reading stringDefBody with a lengthWord8 of up to
    --       the maxBound of Word16, which is bigger than stringDefBodyMaxSize.
    --       This causes a slight mismatch between the size of stringDefBody
    --       read by get and written by put, which means that get followed by
    --       put is not the identity. However, this would only truncate the
    --       stringDefBody if that binary representation was created manually,
    --     rather than via put, so this is likely not an issue.
    MkUnsafeShortText stringDefBody <- get
    pure MkStringDef{stringDefId, stringDefBody}

stringDefBodyLenSize :: Int
stringDefBodyLenSize = 2

stringDefBodyMaxSize :: Int
stringDefBodyMaxSize =
  messageMaxSize
    - messageTagSize
    - stringIdSize
    - stringDefBodyLenSize

-------------------------------------------------------------------------------
-- SourceLocationDefs

instance Binary SourceLocationId where
  put :: SourceLocationId -> Put
  put = putWord64 . coerce

  get :: Get SourceLocationId
  get = coerce getWord64

sourceLocationIdSize :: Int
sourceLocationIdSize = 8

instance Binary SourceLocationDef where
  put :: SourceLocationDef -> Put
  put msg = do
    put $ sourceLocationDefId msg
    putWord32 (sourceLocationDefRow msg)
    putWord32 (sourceLocationDefColumn msg)
    put (sourceLocationDefFilename msg)

  get :: Get SourceLocationDef
  get = MkSourceLocationDef <$> get <*> getWord32 <*> getWord32 <*> get

sourceLocationDefSize :: Int
sourceLocationDefSize =
  sourceLocationIdSize {- sourceLocationDefId -}
    + 4 {- sourceLocationDefRow -}
    + 4 {- sourceLocationDefColumn -}
    + stringIdSize {- sourceLocationDefFilename -}

-------------------------------------------------------------------------------
-- Trim Text to a byte-size limit

-- | A 'Text' whose 'TF.lengthWord8' is at most @'maxBound' :: 'Word16'@ bytes.
newtype ShortText = MkUnsafeShortText Text
  deriving (Eq, Show)

toShortText :: Text -> ShortText
toShortText text =
  MkUnsafeShortText (truncateTextToByteLimit maxBoundWord16 text)
 where
  maxBoundWord16 = fromIntegral (maxBound @Word16)

instance Binary ShortText where
  put :: ShortText -> Put
  put (MkUnsafeShortText text) = do
    putWord16 (fromIntegral (TF.lengthWord8 text))
    putByteString (TE.encodeUtf8 text)

  get :: Get ShortText
  get = do
    lengthWord8 <- fromIntegral <$> getWord16
    bytes <- getByteString lengthWord8
    pure $ MkUnsafeShortText (TE.decodeUtf8Lenient bytes)

-- | @'truncateTextToByteLimit' byteLimit text@ truncates @text@ such that its
--   UTF-8 serialisation fits within @byteLimit@ bytes.
truncateTextToByteLimit :: Int -> Text -> Text
truncateTextToByteLimit byteLimit text
  | TF.lengthWord8 text <= byteLimit = text
  | TF.lengthWord8 text' <= byteLimit = text'
  | otherwise =
      -- @'takeWord8' n@ takes the first n bytes and _expands_ to complete the
      -- last code point, which means it may return up to n+3 bytes. Hence, if
      -- this happens, we drop the final code point.
      assert (byteLimit < TF.lengthWord8 text' && TF.lengthWord8 text' <= byteLimit + 3) $
        T.dropEnd 1 text'
 where
  text' = TF.takeWord8 (fromIntegral byteLimit) text

-------------------------------------------------------------------------------
-- Size Invariants

callStackLenSize :: Int
callStackLenSize = 2

-- | The maximum number of `CallStackFrame`s in a single `Message`.
callStackMaxLen :: Int
callStackMaxLen = callStackMaxLen' messageMaxSize

-- | The size of a serialised `CapabilityId`.
capabilityIdSize :: Int
capabilityIdSize = 4

-- | The size of a serialised `ThreadId`.
threadIdSize :: Int
threadIdSize = 8

-- | The maximum number of `CallStackFrame`s in a single `Message`,
--   with a variable `messageMaxSize`. Used for testing.
callStackMaxLen' :: Int -> Int
callStackMaxLen' messageMaxSize' =
  fromIntegral
    ( messageMaxSize'
        - messageTagSize
        - capabilityIdSize
        - threadIdSize
        - callStackLenSize
    )

-- | Size in bytes of the given 'CallStackFrame'
callStackFrameSize :: CallStackFrame -> Int
callStackFrameSize = \case
  CallStackFrameIpe{} ->
    1 {- CallStackFrameTag -}
      + 8 {- IpeId -}
  CallStackFrameAnn _ Nothing ->
    1 {- CallStackFrameTag -}
      + 8 {- StringId -}
  CallStackFrameAnn _ (Just _) ->
    1 {- CallStackFrameTag -}
      + 8 {- StringId -}
      + 8 {- SourceLocationId -}

callStackFrameMaxSize :: Int
callStackFrameMaxSize =
  17 {- see case for CallStackFrameAnn in callStackFrameSize -}