hercules-ci-agent-0.9.13: src/Hercules/Agent/Socket.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Hercules.Agent.Socket
( withReliableSocket,
checkVersion',
Socket (..),
syncIO,
SocketConfig (..),
)
where
import Control.Concurrent.STM.TBQueue (TBQueue, flushTBQueue, newTBQueue, writeTBQueue)
import Control.Concurrent.STM.TChan (TChan, writeTChan)
import Control.Concurrent.STM.TVar (TVar, modifyTVar, readTVar, writeTVar)
import Control.Monad.IO.Unlift
import Data.Aeson qualified as A
import Data.DList (DList, fromList)
import Data.List (dropWhileEnd, splitAt)
import Data.Semigroup
import Data.Time (NominalDiffTime, addUTCTime, diffUTCTime, getCurrentTime)
import Data.Time.Extras
import Hercules.API.Agent.LifeCycle.ServiceInfo (ServiceInfo)
import Hercules.API.Agent.LifeCycle.ServiceInfo qualified as ServiceInfo
import Hercules.API.Agent.Socket.Frame (Frame)
import Hercules.API.Agent.Socket.Frame qualified as Frame
import Hercules.Agent.STM (atomically, newTChanIO, newTVarIO)
import Katip (KatipContext, Severity (..), katipAddContext, katipAddNamespace, logLocM, sl)
import Network.URI (URI, uriAuthority, uriPath, uriPort, uriQuery, uriRegName, uriScheme)
import Network.WebSockets (Connection, runClientWith)
import Network.WebSockets qualified as WS
import Protolude hiding (atomically, handle, race, race_)
import UnliftIO qualified
import UnliftIO.Async (race, race_)
import UnliftIO.Exception (handle)
import UnliftIO.STM (readTVarIO)
import UnliftIO.Timeout (timeout)
import Wuss (runSecureClientWith)
data Socket r w = Socket
{ write :: w -> STM (),
serviceChan :: TChan r,
sync :: STM (STM ())
}
syncIO :: Socket r w -> IO ()
syncIO = atomically <=< atomically . sync
-- | Parameters to start 'withReliableSocket'.
data SocketConfig ap sp m = SocketConfig
{ makeHello :: m ap,
checkVersion :: sp -> m (Either Text ()),
baseURL :: URI,
path :: Text,
token :: ByteString
}
requiredServiceVersion :: (Int, Int)
requiredServiceVersion = (2, 0)
ackTimeout :: NominalDiffTime
ackTimeout = 60 -- seconds
withReliableSocket :: (A.FromJSON sp, A.ToJSON ap, MonadIO m, MonadUnliftIO m, KatipContext m) => SocketConfig ap sp m -> (Socket sp ap -> m a) -> m a
withReliableSocket socketConfig f = do
writeQueue <- atomically $ newTBQueue 100
agentMessageNextN <- newTVarIO 0
serviceMessageChan <- newTChanIO
highestAcked <- newTVarIO (-1)
let tagPayload p = do
c <- readTVar agentMessageNextN
writeTVar agentMessageNextN (c + 1)
pure $ Frame.Msg {n = c, p = p}
socketThread = runReliableSocket socketConfig writeQueue serviceMessageChan highestAcked
socket =
Socket
{ write = tagPayload >=> writeTBQueue writeQueue,
serviceChan = serviceMessageChan,
sync = do
counterAtSyncStart <- (\n -> n - 1) <$> readTVar agentMessageNextN
pure do
acked <- readTVar highestAcked
guard $ acked >= counterAtSyncStart
}
race socketThread (f socket) <&> either identity identity
checkVersion' :: (Applicative m) => ServiceInfo -> m (Either Text ())
checkVersion' si =
if ServiceInfo.version si < requiredServiceVersion
then pure $ Left $ "Expected service version " <> show requiredServiceVersion
else pure $ Right ()
runReliableSocket :: forall ap sp m. (A.ToJSON ap, A.FromJSON sp, MonadUnliftIO m, KatipContext m) => SocketConfig ap sp m -> TBQueue (Frame ap ap) -> TChan sp -> TVar Integer -> forall a. m a
runReliableSocket socketConfig writeQueue serviceMessageChan highestAcked = katipAddNamespace "Socket" do
expectedAck <- liftIO $ newTVarIO Nothing
(unacked :: TVar (DList (Frame Void ap))) <- newTVarIO mempty
(lastServiceN :: TVar Integer) <- newTVarIO (-1)
let katipExceptionContext e =
katipAddContext (sl "message" (displayException e))
. katipAddContext (sl "exception" (show e :: [Char]))
logWarningPause :: SomeException -> m ()
logWarningPause e | Just WS.ConnectionClosed <- fromException e = do
katipExceptionContext e $ logLocM InfoS "Socket closed. Reconnecting."
liftIO $ threadDelay 10_000_000
logWarningPause e | Just (WS.ParseException "not enough bytes") <- fromException e = do
katipExceptionContext e $ logLocM InfoS "Socket closed prematurely. Reconnecting."
liftIO $ threadDelay 10_000_000
logWarningPause e = do
katipExceptionContext e $ logLocM WarningS "Recovering from exception in socket handler. Reconnecting."
liftIO $ threadDelay 10_000_000
setExpectedAckForMsgs :: [Frame ap ap] -> m ()
setExpectedAckForMsgs msgs =
msgs
& foldMap (\case Frame.Msg {n = n} -> Just $ Max n; _ -> mempty)
& traverse_ (\(Max n) -> setExpectedAck n)
send :: Connection -> [Frame ap ap] -> m ()
send conn = sendSorted . sortBy (compare `on` msgN)
where
sendRaw :: [Frame ap ap] -> m ()
sendRaw msgs = do
liftIO $ WS.sendDataMessages conn (WS.Binary . A.encode <$> msgs)
setExpectedAckForMsgs msgs
sendSorted :: [Frame ap ap] -> m ()
sendSorted [] = pass
sendSorted msgs = do
let (msgsNow, msgsLater) = Data.List.splitAt 100 msgs
sendRaw msgsNow
sendSorted msgsLater
recv :: Connection -> m (Frame sp sp)
recv conn = do
withTimeout ackTimeout (FatalError "Hercules.Agent.Socket.recv timed out") $
liftIO (A.eitherDecode <$> WS.receiveData conn) >>= \case
Left e -> liftIO $ throwIO (FatalError $ "Error decoding service message: " <> toS e)
Right r -> pure r
handshake conn (removeHandshakeTimeout :: IO ()) = katipAddNamespace "Handshake" do
siMsg <- recv conn
case siMsg of
Frame.Oob {o = o'} ->
checkVersion socketConfig o' >>= \case
Left e -> do
send conn [Frame.Exception e]
throwIO $ FatalError "It looks like you're running a development version of hercules-ci-agent that is not yet supported on hercules-ci.com. Please use the stable branch or a tag."
Right _ -> pass
_ -> throwIO $ FatalError "Unexpected message. This is either a bug or you might need to update your agent."
hello <- makeHello socketConfig
send conn [Frame.Oob hello]
ackMsg <- recv conn
case ackMsg of
Frame.Ack {n = n} -> cleanAcknowledged n
_ -> throwIO $ FatalError "Expected acknowledgement. This is either a bug or you might need to update your agent."
liftIO removeHandshakeTimeout
sendUnacked conn
sendUnacked :: Connection -> m ()
sendUnacked conn = do
unackedNow <- readTVarIO unacked
send conn $ Frame.mapOob absurd <$> toList unackedNow
cleanAcknowledged newAck = atomically do
unacked0 <- readTVar unacked
writeTVar unacked $
unacked0
& toList
& filter
( \case
Frame.Msg {n = n} -> n > newAck
Frame.Oob x -> absurd x
Frame.Ack {} -> False
Frame.Exception {} -> False
)
& fromList
modifyTVar highestAcked (max newAck)
-- TODO (performance) IntMap?
readThread conn = katipAddNamespace "Reader" do
forever $ do
msg <- recv conn
case msg of
Frame.Msg {p = pl, n = n} -> atomically do
lastN <- readTVar lastServiceN
-- when recent
when (n > lastN) do
writeTChan serviceMessageChan pl
writeTVar lastServiceN n
writeTBQueue writeQueue (Frame.Ack {n = n})
Frame.Ack {n = n} ->
cleanAcknowledged n
Frame.Oob o -> atomically do
writeTChan serviceMessageChan o
Frame.Exception e -> katipAddContext (sl "message" e) $ logLocM WarningS "Service exception"
writeThread conn = katipAddNamespace "Writer" do
forever do
msgs <- atomically do
-- TODO: make unacked bounded
allMessages <- flushTBQueue writeQueue
when (null allMessages) retry
modifyTVar unacked (<> (allMessages >>= Frame.removeOob & fromList))
pure allMessages
send conn msgs
setExpectedAck :: Integer -> m ()
setExpectedAck n = do
now <- liftIO getCurrentTime
atomically do
writeTVar expectedAck $ Just (n, now)
noAckCleanupThread = noAckCleanupThread' (-1)
noAckCleanupThread' confirmedLastTime = do
(expectedN, sendTime) <- atomically do
readTVar expectedAck
>>= \case
Nothing -> retry
Just (expectedN, _) | expectedN <= confirmedLastTime -> retry
Just a -> pure a
let expectedArrival = ackTimeout `addUTCTime` sendTime
liftIO do
now <- getCurrentTime
let waitTime = expectedArrival `diffUTCTime` now
delayNominalDiffTime waitTime
currentHighestAck <- atomically do
readTVar highestAcked
if expectedN > currentHighestAck
then do
katipAddContext (sl "expectedAck" expectedN <> sl "highestAck" currentHighestAck) do
logLocM Katip.DebugS "Did not receive ack in time. Will reconnect."
-- terminate other threads via race_
pass
else noAckCleanupThread' expectedN
forever do
handle logWarningPause $
withCancelableTimeout handshakeTimeoutMicroseconds HandshakeTimeout \removeTimeout -> do
withConnection' socketConfig $
\conn -> do
katipAddNamespace "Handshake" do
handshake conn removeTimeout
readThread conn `race_` writeThread conn `race_` noAckCleanupThread
handshakeTimeoutMicroseconds :: Int
handshakeTimeoutMicroseconds = 30_000_000
data HandshakeTimeout = HandshakeTimeout
deriving (Show, Exception)
withCancelableTimeout :: (Exception e, MonadUnliftIO m) => Int -> e -> (IO () -> m a) -> m a
withCancelableTimeout delay exc cont = do
requestingThread <- liftIO myThreadId
UnliftIO.withAsync
( liftIO do
threadDelay delay
throwTo requestingThread exc
)
(cont . cancel)
msgN :: Frame o a -> Maybe Integer
msgN Frame.Msg {n = n} = Just n
msgN _ = Nothing
withConnection' :: (MonadUnliftIO m) => SocketConfig any0 any1 m -> (Connection -> m a) -> m a
withConnection' socketConfig m = do
UnliftIO unlift <- askUnliftIO
let opts = WS.defaultConnectionOptions
headers = [("Authorization", "Bearer " <> token socketConfig)]
base = baseURL socketConfig
url = base {uriPath = uriPath base `slash` toS (path socketConfig)}
defaultPort
| uriScheme url == "http:" = 80
| uriScheme url == "https:" = 443
| otherwise = panic "Hercules.Agent.Socket: invalid uri scheme"
port = fromMaybe defaultPort $ do
auth <- uriAuthority url
readMaybe $ dropWhile (== ':') $ uriPort auth
regname = fromMaybe (panic "Hercules.Agent.Socket: url has no regname") $ do
auth <- uriAuthority url
pure $ uriRegName auth
httpPath = uriPath url <> uriQuery url
runSocket
| uriScheme url == "http:" = runClientWith regname port httpPath opts headers
| uriScheme url == "https:" = runSecureClientWith regname (fromIntegral port) httpPath opts headers
| otherwise = panic "Hercules.Agent.Socket: invalid uri scheme"
liftIO $ runSocket $ \conn -> unlift (m conn)
slash :: [Char] -> [Char] -> [Char]
a `slash` b = dropWhileEnd (== '/') a <> "/" <> dropWhile (== '/') b
withTimeout :: (Exception e, MonadIO m, MonadUnliftIO m) => NominalDiffTime -> e -> m a -> m a
withTimeout t e _ | t <= 0 = throwIO e
withTimeout t e m =
timeout (ceiling $ t * 1_000_000) m >>= \case
Nothing -> throwIO e
Just a -> pure a