packages feed

hegel-0.1.0: src/Hegel/Connection.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}

-- | Multiplexed connection and channel abstractions for Hegel.
--
-- This module implements a background-reader model: a dedicated thread reads
-- packets from the input stream and dispatches them to per-channel inboxes.
-- Channels use STM 'TQueue' for incoming messages.
--
-- A 'Connection' manages a pair of 'Handle's (one for reading, one for
-- writing) with multiplexed logical channels. A 'Channel' provides
-- request\/response messaging over a single logical channel.
module Hegel.Connection
  ( -- * Types
    Connection (..)
  , Channel (..)
  , InboxItem (..)
  , RequestError (..)
  , PendingRequest

    -- * Connection lifecycle
  , createConnection
  , closeConnection

    -- * Channel management
  , newChannel
  , connectChannel
  , closeChannel

    -- * Sending
  , sendPacket
  , sendRequest
  , sendRequestCBOR

    -- * Receiving
  , receiveResponse
  , receiveRequest
  , sendResponseValue

    -- * Handshake
  , sendHandshake

    -- * Result handling
  , resultOrError

    -- * Pending requests
  , request
  , pendingGet

    -- * Accessors
  , controlChannel
  , channelId
  , serverHasExited
  , setServerExited
  , isLive
  , sendRequestAndWait
  , sendResponseRaw
  , serverCrashedMessage
  ) where

import Codec.CBOR.Term (Term (..))
import Control.Concurrent.Async (Async, async, cancel)
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
import Control.Concurrent.STM
  ( STM
  , TQueue
  , TVar
  , atomically
  , check
  , newTQueueIO
  , newTVarIO
  , orElse
  , readTQueue
  , readTVar
  , readTVarIO
  , registerDelay
  , writeTQueue
  , writeTVar
  , modifyTVar'
  )
import Control.Exception (Exception, SomeException, catch, throwIO)
import Control.Monad (unless, when)
import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import Data.Word (Word32)
import System.IO (Handle, hClose, hSetBinaryMode)

import Hegel.Protocol

-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------

-- | Handshake string sent by the client to initiate the protocol.
handshakeString :: ByteString
handshakeString = "hegel_handshake_start"

-- | Default timeout in microseconds for channel operations (30 seconds).
channelTimeoutMicros :: Int
channelTimeoutMicros = 30 * 1000000

-- | Message displayed when the server has crashed.
serverCrashedMessage :: String
serverCrashedMessage =
  "The hegel server process has exited unexpectedly. Check .hegel/server.log for details."

-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------

-- | Sentinel value placed in a channel's inbox when the connection shuts down.
data InboxItem
  = Pkt Packet
  -- ^ A normal packet.
  | Shutdown
  -- ^ The connection has been closed.
  deriving (Show, Eq)

-- | Error response from the peer.
data RequestError = RequestError
  { reqErrorMessage :: String
  , reqErrorType :: String
  , reqErrorData :: [(Term, Term)]
  }
  deriving (Show)

instance Exception RequestError

-- | Multiplexed connection to a Hegel peer.
data Connection = Connection
  { connReadHandle :: Handle
  -- ^ Handle for reading packets.
  , connWriteHandle :: Handle
  -- ^ Handle for writing packets.
  , connNextChannelId :: TVar Int
  -- ^ Counter for allocating new channel IDs.
  , connChannels :: TVar (Map Word32 Channel)
  -- ^ Map from channel ID to live channel.
  , connRunning :: TVar Bool
  -- ^ Whether the connection is still active.
  , connServerExited :: TVar Bool
  -- ^ Whether the server process has exited unexpectedly.
  , connWriterLock :: MVar ()
  -- ^ Lock for serializing writes to the output handle.
  , connReaderThread :: Async ()
  -- ^ Background reader thread.
  , connDebug :: Bool
  -- ^ Whether debug logging is enabled.
  }

-- | Logical channel for request\/response messaging.
data Channel = Channel
  { chanId :: Word32
  -- ^ The channel ID.
  , chanConn :: Connection
  -- ^ The connection this channel belongs to.
  , chanInbox :: TQueue InboxItem
  -- ^ Inbox for incoming packets dispatched by the reader thread.
  , chanResponses :: TVar (Map Word32 ByteString)
  -- ^ Map from message ID to response payload (filled by 'processOneMessage').
  , chanRequests :: TQueue Packet
  -- ^ Queue of incoming request packets (filled by 'processOneMessage').
  , chanNextMessageId :: TVar Word32
  -- ^ Next message ID to use for outgoing requests.
  , chanClosed :: TVar Bool
  -- ^ Whether this channel has been closed.
  }

-- | A pending request handle that caches the decoded response.
data PendingRequest = PendingRequest
  { prChannel :: Channel
  , prMessageId :: Word32
  , prValue :: IORef (Maybe Term)
  }

-- ---------------------------------------------------------------------------
-- Connection lifecycle
-- ---------------------------------------------------------------------------

-- | Create a new connection using separate handles for reading and writing.
--
-- A control channel (channel 0) is automatically created and a background
-- reader thread is spawned. Both handles are set to binary mode.
createConnection :: Handle -> Handle -> Bool -> IO Connection
createConnection readH writeH debug = do
  hSetBinaryMode readH True
  hSetBinaryMode writeH True

  nextChanId <- newTVarIO 1
  channels <- newTVarIO Map.empty
  running <- newTVarIO True
  serverExited <- newTVarIO False
  writerLock <- newMVar ()

  -- Create a placeholder connection (reader thread filled in below)
  let mkConn reader = Connection
        { connReadHandle = readH
        , connWriteHandle = writeH
        , connNextChannelId = nextChanId
        , connChannels = channels
        , connRunning = running
        , connServerExited = serverExited
        , connWriterLock = writerLock
        , connReaderThread = reader
        , connDebug = debug
        }

  -- Create control channel (channel 0)
  controlInbox <- newTQueueIO
  controlResponses <- newTVarIO Map.empty
  controlRequests <- newTQueueIO
  controlNextMsgId <- newTVarIO 1
  controlClosed <- newTVarIO False

  -- We need the connection reference for the channel, but the connection
  -- needs the reader thread. Use a two-phase approach: create a dummy
  -- Async, build everything, then spawn the real reader.
  dummyAsync <- async (return ())

  let tempConn = mkConn dummyAsync

  let ctrl = Channel
        { chanId = 0
        , chanConn = tempConn
        , chanInbox = controlInbox
        , chanResponses = controlResponses
        , chanRequests = controlRequests
        , chanNextMessageId = controlNextMsgId
        , chanClosed = controlClosed
        }

  atomically $ writeTVar channels (Map.singleton 0 ctrl)

  -- Spawn background reader thread
  reader <- async (readerLoop tempConn)

  -- The connection returned uses the same TVars so the reader thread's
  -- reference via tempConn is equivalent. We return a version with the
  -- real Async handle for cleanup.
  let conn = mkConn reader

  -- Update the control channel to reference the real connection
  -- (shares the same TVars, so this is just for the Async handle)
  let ctrl' = ctrl { chanConn = conn }
  atomically $ writeTVar channels (Map.singleton 0 ctrl')

  return conn

-- | Close the connection and signal all live channels.
closeConnection :: Connection -> IO ()
closeConnection conn = do
  isRunning <- readTVarIO (connRunning conn)
  if not isRunning
    then return ()
    else do
      atomically $ writeTVar (connRunning conn) False
      catch (hClose (connReadHandle conn)) (\(_ :: SomeException) -> return ())
      -- Always attempt to close the write handle; if it is the same as
      -- the read handle, hClose will simply fail silently.
      catch (hClose (connWriteHandle conn)) (\(_ :: SomeException) -> return ())
      signalAllChannels conn
      cancel (connReaderThread conn)

-- ---------------------------------------------------------------------------
-- Background reader
-- ---------------------------------------------------------------------------

-- | Background reader thread function. Reads packets from the connection's
-- read handle and dispatches them to the appropriate channel's inbox.
-- Exits when the stream closes or an error occurs.
readerLoop :: Connection -> IO ()
readerLoop conn = do
  let loop = do
        isRunning <- readTVarIO (connRunning conn)
        when isRunning $ do
          pkt <- readPacket (connReadHandle conn)
          dispatchPacket conn pkt
          loop

  catch loop (\(_ :: SomeException) -> do
    atomically $ writeTVar (connServerExited conn) True)

  signalAllChannels conn

-- | Dispatch a single packet to the appropriate channel.
dispatchPacket :: Connection -> Packet -> IO ()
dispatchPacket conn pkt
  | packetPayload pkt == closeChannelPayload = return ()
  | otherwise = do
      mChan <- Map.lookup (packetChannelId pkt) <$> readTVarIO (connChannels conn)
      case mChan of
        Just ch -> atomically $ writeTQueue (chanInbox ch) (Pkt pkt)
        Nothing ->
          -- For non-reply messages to non-existent channels, send error back
          unless (packetIsReply pkt) $ do
            let errorMsg = "Message sent to non-existent channel "
                  ++ show (packetChannelId pkt)
                errorPayload = encodeTerm $
                  TMap [(TString "error", TString (T.pack errorMsg))]
            catch
              (sendPacket conn Packet
                { packetChannelId = packetChannelId pkt
                , packetMessageId = packetMessageId pkt
                , packetIsReply = True
                , packetPayload = errorPayload
                })
              (\(_ :: SomeException) -> return ())

-- | Push 'Shutdown' into all live channels' inboxes.
signalAllChannels :: Connection -> IO ()
signalAllChannels conn = do
  chans <- readTVarIO (connChannels conn)
  mapM_ (\ch -> atomically $ writeTQueue (chanInbox ch) Shutdown) (Map.elems chans)

-- ---------------------------------------------------------------------------
-- Channel management
-- ---------------------------------------------------------------------------

-- | Create a new logical channel on the connection.
--
-- Client channels use odd IDs: @(counter \<\< 1) | 1@.
newChannel :: Connection -> IO Channel
newChannel conn = do
  chanid <- atomically $ do
    counter <- readTVar (connNextChannelId conn)
    let cid = fromIntegral ((counter `shiftL` 1) .|. 1) :: Word32
    writeTVar (connNextChannelId conn) (counter + 1)
    return cid

  inbox <- newTQueueIO
  responses <- newTVarIO Map.empty
  requests <- newTQueueIO
  nextMsgId <- newTVarIO 1
  closed <- newTVarIO False

  let channel = Channel
        { chanId = chanid
        , chanConn = conn
        , chanInbox = inbox
        , chanResponses = responses
        , chanRequests = requests
        , chanNextMessageId = nextMsgId
        , chanClosed = closed
        }

  atomically $ modifyTVar' (connChannels conn) (Map.insert chanid channel)
  return channel

-- | Connect to a channel created by the peer (using the peer-assigned ID).
connectChannel :: Connection -> Word32 -> IO Channel
connectChannel conn cid = do
  exists <- atomically $ do
    chans <- readTVar (connChannels conn)
    return (Map.member cid chans)

  if exists
    then ioError $ userError $
      "Channel already connected as " ++ show cid ++ "."
    else do
      inbox <- newTQueueIO
      responses <- newTVarIO Map.empty
      requests <- newTQueueIO
      nextMsgId <- newTVarIO 1
      closed <- newTVarIO False

      let channel = Channel
            { chanId = cid
            , chanConn = conn
            , chanInbox = inbox
            , chanResponses = responses
            , chanRequests = requests
            , chanNextMessageId = nextMsgId
            , chanClosed = closed
            }

      atomically $ modifyTVar' (connChannels conn) (Map.insert cid channel)
      return channel

-- | Close a channel and notify the peer. Idempotent.
closeChannel :: Channel -> IO ()
closeChannel ch = do
  alreadyClosed <- readTVarIO (chanClosed ch)
  if alreadyClosed
    then return ()
    else do
      atomically $ writeTVar (chanClosed ch) True
      isRunning <- readTVarIO (connRunning (chanConn ch))
      when isRunning $
        sendPacket (chanConn ch) Packet
          { packetPayload = closeChannelPayload
          , packetMessageId = closeChannelMessageId
          , packetChannelId = chanId ch
          , packetIsReply = False
          }

-- ---------------------------------------------------------------------------
-- Sending
-- ---------------------------------------------------------------------------

-- | Send a packet on the connection, thread-safe via the writer lock.
sendPacket :: Connection -> Packet -> IO ()
sendPacket conn pkt =
  withMVar (connWriterLock conn) $ \() ->
    writePacket (connWriteHandle conn) pkt

-- | Send raw bytes as a request on a channel. Returns the message ID for
-- matching the response.
sendRequest :: Channel -> ByteString -> IO Word32
sendRequest ch payload = do
  msgId <- atomically $ do
    mid <- readTVar (chanNextMessageId ch)
    writeTVar (chanNextMessageId ch) (mid + 1)
    return mid
  sendPacket (chanConn ch) Packet
    { packetPayload = payload
    , packetChannelId = chanId ch
    , packetIsReply = False
    , packetMessageId = msgId
    }
  return msgId

-- | Send a CBOR-encoded request on a channel. Returns the message ID.
sendRequestCBOR :: Channel -> Term -> IO Word32
sendRequestCBOR ch term = sendRequest ch (encodeTerm term)

-- ---------------------------------------------------------------------------
-- Receiving
-- ---------------------------------------------------------------------------

-- | Pop an item from the channel's inbox, with a timeout in microseconds.
-- Returns 'Nothing' on timeout.
popInboxItem :: Channel -> Int -> IO InboxItem
popInboxItem ch timeoutUs = do
  timedOut <- registerDelay timeoutUs
  result <- atomically $
    (Just <$> readTQueue (chanInbox ch))
    `orElse`
    (do expired <- readTVar timedOut
        check expired
        return Nothing)
  case result of
    Just item -> return item
    Nothing -> do
      isClosed <- readTVarIO (chanClosed ch)
      if isClosed
        then ioError $ userError $ "Channel " ++ show (chanId ch) ++ " is closed"
        else do
          exited <- readTVarIO (connServerExited (chanConn ch))
          if exited
            then ioError $ userError serverCrashedMessage
            else ioError $ userError $
              "Timed out after " ++ show (timeoutUs `div` 1000000)
                ++ "s waiting for a message on channel " ++ show (chanId ch)

-- | Process one incoming message for a channel. Dispatches replies to the
-- responses map and requests to the requests queue.
processOneMessage :: Channel -> Int -> IO ()
processOneMessage ch timeoutUs = do
  item <- popInboxItem ch timeoutUs
  case item of
    Shutdown -> ioError $ userError "Connection closed"
    Pkt pkt ->
      if packetIsReply pkt
        then atomically $ modifyTVar' (chanResponses ch)
              (Map.insert (packetMessageId pkt) (packetPayload pkt))
        else atomically $ writeTQueue (chanRequests ch) pkt

-- | Wait for a raw response to a request with the given message ID.
receiveResponseRaw :: Channel -> Word32 -> Int -> IO ByteString
receiveResponseRaw ch msgId timeoutUs = do
  let waitLoop = do
        mResp <- atomically $ do
          resps <- readTVar (chanResponses ch)
          case Map.lookup msgId resps of
            Just payload -> do
              writeTVar (chanResponses ch) (Map.delete msgId resps)
              return (Just payload)
            Nothing -> return Nothing
        case mResp of
          Just payload -> return payload
          Nothing -> do
            processOneMessage ch timeoutUs
            waitLoop
  waitLoop

-- | Wait for and decode a response, extracting the result or raising
-- 'RequestError'.
receiveResponse :: Channel -> Word32 -> IO Term
receiveResponse ch msgId = do
  raw <- receiveResponseRaw ch msgId channelTimeoutMicros
  resultOrError (decodeTerm raw)

-- | Receive the next incoming request on a channel. Returns the message ID
-- and the decoded CBOR term.
receiveRequest :: Channel -> IO (Word32, Term)
receiveRequest ch = do
  let waitLoop = do
        mPkt <- atomically $ tryReadTQueue (chanRequests ch)
        case mPkt of
          Just pkt -> return (packetMessageId pkt, decodeTerm (packetPayload pkt))
          Nothing -> do
            processOneMessage ch channelTimeoutMicros
            waitLoop
  waitLoop
  where
    tryReadTQueue :: TQueue a -> STM (Maybe a)
    tryReadTQueue q = (Just <$> readTQueue q) `orElse` return Nothing

-- | Send a success response with a value wrapped as @{\"result\": value}@.
sendResponseValue :: Channel -> Word32 -> Term -> IO ()
sendResponseValue ch msgId value = do
  let payload = encodeTerm $ TMap [(TString "result", value)]
  sendPacket (chanConn ch) Packet
    { packetPayload = payload
    , packetChannelId = chanId ch
    , packetIsReply = True
    , packetMessageId = msgId
    }

-- ---------------------------------------------------------------------------
-- Result handling
-- ---------------------------------------------------------------------------

-- | Extract the @\"result\"@ field from a CBOR map, or throw 'RequestError'
-- if an @\"error\"@ field is present.
resultOrError :: Term -> IO Term
resultOrError body = do
  let pairs = extractMap body
      findText key = case lookup (TString key) pairs of
        Just v -> T.unpack (extractText v)
        Nothing -> ""
  case lookup (TString "error") pairs of
    Just _ -> do
      let msg = findText "error"
          errorType = findText "type"
          dat = filter (\(k, _) -> k /= TString "error" && k /= TString "type") pairs
      throwIO $ RequestError msg errorType dat
    Nothing ->
      case lookup (TString "result") pairs of
        Just v -> return v
        Nothing -> ioError $ userError "Response has neither 'result' nor 'error'"

-- ---------------------------------------------------------------------------
-- Pending requests
-- ---------------------------------------------------------------------------

-- | Send a CBOR request and return a 'PendingRequest' handle.
request :: Channel -> Term -> IO PendingRequest
request ch term = do
  msgId <- sendRequestCBOR ch term
  ref <- newIORef Nothing
  return PendingRequest
    { prChannel = ch
    , prMessageId = msgId
    , prValue = ref
    }

-- | Block until the response arrives and return the result.
--
-- Caches the response so subsequent calls return the same value or raise
-- the same error.
pendingGet :: PendingRequest -> IO Term
pendingGet pr = do
  cached <- readIORef (prValue pr)
  case cached of
    Just v -> resultOrError v
    Nothing -> do
      raw <- receiveResponseRaw (prChannel pr) (prMessageId pr) channelTimeoutMicros
      let v = decodeTerm raw
      writeIORef (prValue pr) (Just v)
      resultOrError v

-- ---------------------------------------------------------------------------
-- Handshake
-- ---------------------------------------------------------------------------

-- | Initiate the handshake as a client. Returns the server protocol version
-- string (e.g. @\"0.1\"@).
sendHandshake :: Connection -> IO String
sendHandshake conn = do
  -- Get control channel (channel 0)
  mCh <- atomically $ do
    chans <- readTVar (connChannels conn)
    return (Map.lookup 0 chans)
  ch <- case mCh of
    Just c -> return c
    Nothing -> ioError $ userError "Internal error: no control channel"

  msgId <- sendRequest ch handshakeString
  raw <- receiveResponseRaw ch msgId channelTimeoutMicros

  let response = BS8.unpack raw
      prefix = "Hegel/"
  if take 6 response /= prefix
    then ioError $ userError $ "Bad handshake response: " ++ show response
    else return (drop 6 response)

-- ---------------------------------------------------------------------------
-- Additional accessors
-- ---------------------------------------------------------------------------

-- | Get the control channel (channel 0).
controlChannel :: Connection -> IO Channel
controlChannel conn = do
  mCh <- atomically $ do
    chans <- readTVar (connChannels conn)
    return (Map.lookup 0 chans)
  case mCh of
    Just c -> return c
    Nothing -> ioError $ userError "Internal error: no control channel"

-- | Get the channel ID.
channelId :: Channel -> Word32
channelId = chanId

-- | Check if the server process has exited.
serverHasExited :: Connection -> IO Bool
serverHasExited conn = readTVarIO (connServerExited conn)

-- | Set the server-exited flag.
setServerExited :: Connection -> IO ()
setServerExited conn = atomically $ writeTVar (connServerExited conn) True

-- | Check if the connection is still active.
isLive :: Connection -> IO Bool
isLive conn = readTVarIO (connRunning conn)

-- | Send a CBOR request and wait for the decoded response (convenience).
sendRequestAndWait :: Channel -> Term -> IO Term
sendRequestAndWait ch msg = do
  msgId <- sendRequestCBOR ch msg
  receiveResponse ch msgId

-- | Send raw bytes as a reply.
sendResponseRaw :: Channel -> Word32 -> ByteString -> IO ()
sendResponseRaw ch msgId payload =
  sendPacket (chanConn ch) Packet
    { packetChannelId = chanId ch
    , packetMessageId = msgId
    , packetIsReply = True
    , packetPayload = payload
    }