packages feed

sandwich-0.3.1.0: src/Test/Sandwich/Formatters/Socket/Server.hs

module Test.Sandwich.Formatters.Socket.Server (
  socketServer
  ) where

import Control.Concurrent.STM
import Control.Monad
import Control.Monad.Logger
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.IORef
import Data.String.Interpolate
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Data.Time
import GHC.Stats
import Network.Socket
import Network.Socket.ByteString (recv, sendAll)
import System.Directory (removeFile)
import Test.Sandwich.Formatters.Socket.Commands
import Test.Sandwich.Instrumentation (formatRtsStats)
import Test.Sandwich.ManagedAsync
import Test.Sandwich.Types.RunTree
import Test.Sandwich.Types.Spec
import UnliftIO.Concurrent (threadDelay)
import UnliftIO.Exception


-- | Bidirectional Unix socket server. Each client connection reads line-based
-- commands and sends back responses terminated by a line containing just ".".
socketServer :: T.Text -> FilePath -> [RunNode BaseContext] -> TChan (Int, String, LogEntry) -> TChan NodeEvent -> IO ()
socketServer runId socketPath rts logBroadcast eventBroadcast = do
  -- Clean up any existing socket file
  removeFile socketPath `catch` \(_ :: IOError) -> return ()

  bracket (socket AF_UNIX Stream defaultProtocol) close $ \sock -> do
    bind sock (SockAddrUnix socketPath)
    listen sock 5
    forever $ do
      (conn, _) <- accept sock
      void $ managedAsync runId "socket-connection" $ handleConnection conn rts logBroadcast eventBroadcast

handleConnection :: Socket -> [RunNode BaseContext] -> TChan (Int, String, LogEntry) -> TChan NodeEvent -> IO ()
handleConnection conn rts logBroadcast eventBroadcast = do
  bufRef <- newIORef BS.empty
  handle (\(_ :: IOError) -> close conn) $ do
    sendAll conn "Connected to sandwich socket formatter. Type \"help\" for commands.\n\n> "
    forever $ do
      line <- readLine conn bufRef
      now <- getCurrentTime
      let cmd = BS8.unpack (stripCR line)
      case words cmd of
        ["stream-logs"] -> streamLogs conn logBroadcast
        ["stream-events"] -> streamEvents conn eventBroadcast
        ["stream-rts-stats"] -> streamRtsStats conn
        _ -> do
          response <- handleCommand rts now cmd
          unless (null response) $ do
            sendAll conn (BS8.pack response)
            sendAll conn "\n"
          sendAll conn "> "

-- | Stream all log entries to the client until the connection is closed.
-- This is a blocking operation that never returns to the command loop.
streamLogs :: Socket -> TChan (Int, String, LogEntry) -> IO ()
streamLogs conn broadcastChan = do
  sendAll conn "Streaming logs (disconnect to stop)...\n"
  -- Duplicate the broadcast channel to get our own read position
  chan <- atomically $ dupTChan broadcastChan
  handle (\(_ :: IOError) -> return ()) $ forever $ do
    (nodeId, nodeLabel, LogEntry {..}) <- atomically $ readTChan chan
    let levelStr :: String
        levelStr = case logEntryLevel of
          LevelDebug -> "DEBUG"
          LevelInfo -> "INFO"
          LevelWarn -> "WARN"
          LevelError -> "ERROR"
          LevelOther t -> show t
        msgStr = BS8.unpack logEntryStr
        formatted = [i|#{show logEntryTime} [#{levelStr}] [#{nodeId}] #{nodeLabel}: #{msgStr}\n|]
    sendAll conn (BS8.pack formatted)

-- | Stream node lifecycle events (started, done) to the client.
streamEvents :: Socket -> TChan NodeEvent -> IO ()
streamEvents conn broadcastChan = do
  sendAll conn "Streaming events (disconnect to stop)...\n"
  chan <- atomically $ dupTChan broadcastChan
  handle (\(_ :: IOError) -> return ()) $ forever $ do
    NodeEvent {..} <- atomically $ readTChan chan
    let typeStr :: String
        typeStr = case nodeEventType of
          EventStarted -> "STARTED"
          EventDone Success -> "DONE:OK"
          EventDone (Failure (Pending {})) -> "DONE:PENDING"
          EventDone (Failure reason) -> [i|DONE:FAIL: #{showFailureReasonBrief reason}|]
          EventDone DryRun -> "DONE:DRYRUN"
          EventDone Cancelled -> "DONE:CANCELLED"
          EventSetupStarted -> "SETUP:STARTED"
          EventSetupFinished -> "SETUP:FINISHED"
          EventTeardownStarted -> "TEARDOWN:STARTED"
          EventTeardownFinished -> "TEARDOWN:FINISHED"
          EventMilestone msg -> [i|MILESTONE: #{msg}|]
          EventEndOfStream -> "END_OF_STREAM"
        formatted = [i|#{show nodeEventTime} [#{nodeEventId}] #{nodeEventLabel}: #{typeStr}\n|]
    sendAll conn (BS8.pack formatted)

showFailureReasonBrief :: FailureReason -> String
showFailureReasonBrief (Reason {failureReason}) = failureReason
showFailureReasonBrief (ChildrenFailed {failureNumChildren}) = [i|#{failureNumChildren} children failed|]
showFailureReasonBrief _ = "(see node detail)"

-- | Stream RTS stats to the client every second until the connection is closed.
-- Requires the program to be run with +RTS -T for stats to be available.
streamRtsStats :: Socket -> IO ()
streamRtsStats conn = do
  enabled <- getRTSStatsEnabled
  if not enabled
    then sendAll conn "RTS stats not available. Run with +RTS -T to enable.\n> "
    else do
      sendAll conn "Streaming RTS stats every 1s (disconnect to stop)...\n"
      handle (\(_ :: IOError) -> return ()) $ forever $ do
        stats <- getRTSStats
        let gc' = gc stats
        now <- getCurrentTime
        let line = formatRtsStats now stats gc'
        sendAll conn "\n\n"
        sendAll conn (T.encodeUtf8 line)
        threadDelay 1_000_000

-- | Read a single line from the socket, buffering leftover bytes.
-- Returns the line without the trailing newline.
readLine :: Socket -> IORef BS.ByteString -> IO BS.ByteString
readLine conn bufRef = do
  buf <- readIORef bufRef
  case BS8.elemIndex '\n' buf of
    Just idx -> do
      let (line, rest) = BS.splitAt idx buf
      writeIORef bufRef (BS.drop 1 rest) -- skip the '\n'
      return line
    Nothing -> do
      chunk <- recv conn 4096
      when (BS.null chunk) $ throwIO (userError "connection closed")
      writeIORef bufRef (buf <> chunk)
      readLine conn bufRef

-- | Strip trailing carriage return (for clients that send \r\n)
stripCR :: BS.ByteString -> BS.ByteString
stripCR bs
  | BS.null bs = bs
  | BS8.last bs == '\r' = BS.init bs
  | otherwise = bs