grapesy-1.1.0: test-grapesy/Test/Sanity/Disconnect.hs
{-# OPTIONS_GHC -Wno-orphans #-}
-- | Handling of client or server disconnections occurring with ongoing RPCs on
-- a shared connection.
--
-- When a server disconnects, we expect:
--
-- 1. All current calls fail with 'Client.ServerDisconnected'
-- 2. Future calls (after reconnection) succeed
--
-- When a client disconnects, we expect:
--
-- 1. The handlers dealing with that client (i.e. on that connection) should
-- fail with 'Server.ClientDisconnected'
-- 2. Future calls (after reconnection) succeed
module Test.Sanity.Disconnect (tests) where
import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception
import Control.Monad
import Data.ByteString.Lazy qualified as Lazy (ByteString)
import Data.IORef
import Data.Word
import Foreign.C.Types (CInt(..))
import Network.Socket
import System.Posix
import Test.Tasty
import Test.Tasty.HUnit
import Text.Read hiding (step)
import Network.GRPC.Client qualified as Client
import Network.GRPC.Client.Binary qualified as Binary
import Network.GRPC.Common
import Network.GRPC.Server qualified as Server
import Network.GRPC.Server.Binary qualified as Binary
import Network.GRPC.Server.Run
import Proto.API.Trivial
import Test.Util
{-------------------------------------------------------------------------------
Top-level
-------------------------------------------------------------------------------}
tests :: TestTree
tests = testGroup "Test.Sanity.Disconnect" [
testCase "client" test_clientDisconnect
, testCase "server" test_serverDisconnect
]
{-------------------------------------------------------------------------------
Disconnecting clients
-------------------------------------------------------------------------------}
-- | Two separate clients make many concurrent calls, one of them disconnects.
test_clientDisconnect :: Assertion
test_clientDisconnect = do
-- Create the server
disconnectCounter1 <- newIORef 0
disconnectCounter2 <- newIORef 0
server <-
Server.mkGrpcServer def [
Server.someRpcHandler $
Server.mkRpcHandler @RPC1 $ echoHandler (Just disconnectCounter1)
, Server.someRpcHandler $
Server.mkRpcHandler @RPC2 $ echoHandler (Just disconnectCounter2)
]
-- Start server
let serverConfig = ServerConfig {
serverInsecure = Just $ InsecureConfig {
insecureHost = Just "127.0.0.1"
, insecurePort = 0
}
, serverSecure = Nothing
}
portSignal <- newEmptyMVar
void $ forkIO $ forkServer def serverConfig server $ \runningServer -> do
putMVar portSignal =<< getServerPort runningServer
waitServer runningServer
-- Wait for the server to signal its port
serverPort <- readMVar portSignal
let serverAddress =
Client.ServerInsecure Client.Address {
addressHost = "127.0.0.1"
, addressPort = serverPort
, addressAuthority = Nothing
}
-- Start a client in a separate process
let numCalls = 10
dyingChild <- forkProcess $
Client.withConnection def serverAddress $ \conn -> do
inLockstep conn (Proxy @RPC1) numCalls NeverTerminate $ \results _getFinal -> do
-- Wait until we are sure that all clients have started their RPC,
-- then kill the process. This avoids race conditions and guarantees
-- that the server will see @numCalls@ clients disconnecting.
_ <- waitForHistoryOfMinLen results 1
c_exit 1
-- Start two more clients; these will not disconnect
let numSteps = 5
(result1, result2) <- concurrently
( Client.withConnection def serverAddress $ \conn -> do
inLockstep conn (Proxy @RPC1) numCalls (TerminateAfter numSteps) $ \_results getFinal ->
getFinal
)
( Client.withConnection def serverAddress $ \conn -> do
inLockstep conn (Proxy @RPC2) numCalls (TerminateAfter numSteps) $ \_results getFinal ->
getFinal
)
-- Wait for the forked process to terminate
_status <- getProcessStatus True False dyingChild
-- All calls by clients in /this/ process (not the ones we killed) should
-- have finished normally
let expectedResult = [
replicate numCalls (StepOk i)
| i <- reverse [1 .. numSteps]
]
assertEqual "" expectedResult result1
assertEqual "" expectedResult result2
-- We should also see only @numCalls@ client disconnects for the first
-- handler and none for the second
clientDisconnects1 <- readIORef disconnectCounter1
clientDisconnects2 <- readIORef disconnectCounter2
assertEqual "" numCalls clientDisconnects1
assertEqual "" 0 clientDisconnects2
-- We need to use this to properly simulate the execution environment crashing
-- in an unrecoverable way. In particular, we don't want to give the program a
-- chance to do any of its normal exception handling/cleanup behavior.
foreign import ccall unsafe "exit" c_exit :: CInt -> IO ()
{-------------------------------------------------------------------------------
Disconnecting servers
-------------------------------------------------------------------------------}
-- | Client makes many concurrent calls, server disconnects
test_serverDisconnect :: Assertion
test_serverDisconnect = withTemporaryFile $ \ipcFile -> do
-- We use a temporary file as a very rudimentary means of inter-process
-- communication so the server (which runs in a separate process) can make
-- the client aware of the port it is assigned by the OS.
let ipcWrite :: PortNumber -> IO ()
ipcWrite port = do
writeFile ipcFile (show port)
ipcRead :: IO PortNumber
ipcRead = do
fmap (readMaybe @PortNumber) (readFile ipcFile) >>= \case
Nothing -> do
ipcRead
Just p -> do
writeFile ipcFile ""
return p
-- Create the server
server <-
Server.mkGrpcServer def [
Server.someRpcHandler $
Server.mkRpcHandler @RPC1 $ echoHandler Nothing
]
let serverConfig = ServerConfig {
serverInsecure = Just $ InsecureConfig {
insecureHost = Just "127.0.0.1"
, insecurePort = 0
}
, serverSecure = Nothing
}
-- Starts the server in a new process. Gives back an action that kills
-- the created server process.
startServer :: IO (IO ())
startServer = do
serverPid <-
forkProcess $
forkServer def serverConfig server $ \runningServer -> do
ipcWrite =<< getServerPort runningServer
waitServer runningServer
return $ signalProcess sigKILL serverPid
-- Start server, get the initial port
killServer <- startServer
port1 <- ipcRead
signalRestart <- newEmptyMVar
let serverAddress port =
Client.ServerInsecure Client.Address {
addressHost = "127.0.0.1"
, addressPort = port
, addressAuthority = Nothing
}
reconnectPolicy :: Client.ReconnectPolicy
reconnectPolicy = go 0
where
go :: Int -> Client.ReconnectPolicy
go n
| n == 5
= Client.ReconnectPolicy $ do
killRestarted <- startServer
port2 <- ipcRead
putMVar signalRestart killRestarted
return $ Client.DoReconnect Client.Reconnect {
Client.nextPolicy =
Client.ReconnectPolicy $
pure $ Client.DoReconnect Client.Reconnect {
Client.reconnectTo =
Client.ReconnectToNew $ serverAddress port2
, Client.nextPolicy =
Client.ReconnectPolicy $ pure Client.DontReconnect
, Client.onReconnect = Nothing
}
, Client.reconnectTo = Client.ReconnectToOriginal
, Client.onReconnect = def
}
| otherwise
= Client.ReconnectPolicy $ do
threadDelay 10000
return $
Client.DoReconnect Client.Reconnect {
reconnectTo = Client.ReconnectToOriginal
, onReconnect = def
, nextPolicy = go (n + 1)
}
connParams :: Client.ConnParams
connParams = def { Client.connReconnectPolicy = reconnectPolicy }
Client.withConnection connParams (serverAddress port1) $ \conn -> do
let numCalls = 10
results <-
inLockstep conn (Proxy @RPC1) numCalls NeverTerminate $ \results getFinal -> do
-- Once all clients have started their RPC, kill the server
_ <- waitForHistoryOfMinLen results 1
killServer
getFinal
-- All calls should have failed (but we don't know in which step)
assertEqual "" numCalls $ length $ filter stepFailed (concat results)
-- New calls should succeed (after reconnection)
killRestarted <- takeMVar signalRestart
result <-
inLockstep conn (Proxy @RPC1) numCalls (TerminateAfter 1) $ \_results getFinal ->
getFinal
let expectedResult = [replicate numCalls $ StepOk 1]
assertEqual "" expectedResult result
-- Do not leave the server process hanging around
killRestarted
{-------------------------------------------------------------------------------
Auxiliary: echo handler
-------------------------------------------------------------------------------}
-- | Echos any input
echoHandler ::
TrivialRpc rpc
=> Maybe (IORef Int)
-> Server.Call rpc -> IO ()
echoHandler disconnectCounter call =
trackDisconnects disconnectCounter $ loop
where
loop :: IO ()
loop = do
inp <- Binary.recvInput @Word64 call
case inp of
StreamElem n -> Binary.sendNextOutput @Word64 call n >> loop
FinalElem n _ -> Binary.sendFinalOutput @Word64 call (n, NoMetadata)
NoMoreElems _ -> Server.sendTrailers call NoMetadata
trackDisconnects :: Maybe (IORef Int) -> IO () -> IO ()
trackDisconnects Nothing = id
trackDisconnects (Just counter) =
handle $ \(_e :: Server.ClientDisconnected) ->
atomicModifyIORef' counter $ \n -> (n + 1, ())
{-------------------------------------------------------------------------------
Bunch of clients all executing in lockstep
-------------------------------------------------------------------------------}
data NumSteps = TerminateAfter Int | NeverTerminate
data Results = Results {
-- | Results for the current step
resultsCurr :: TVar [StepResult]
-- | Number of the current step
, resultsStep :: Int
-- | Previous results (in reverse order)
, resultsHist :: [[StepResult]]
}
data StepResult = StepOk Int | StepFailed SomeException
deriving stock (Show)
stepFailed :: StepResult -> Bool
stepFailed StepOk{} = False
stepFailed StepFailed{} = True
instance Eq StepResult where
StepOk i == StepOk i' = i == i'
StepFailed _ == StepFailed _ = True -- the exception is merely for debugging
StepOk _ == StepFailed _ = False
StepFailed _ == StepOk _ = False
initResults :: IO (TVar Results)
initResults = do
resultsCurr <- newTVarIO []
newTVarIO Results{
resultsCurr
, resultsStep = 1
, resultsHist = []
}
-- | Keep collecting results (never terminates)
collectResults :: Int -> TVar Results -> IO a
collectResults numClients results =
forever $
atomically $ do
Results{resultsCurr, resultsStep, resultsHist} <- readTVar results
current <- readTVar resultsCurr
if length current < numClients
then retry
else do
current' <- newTVar []
writeTVar results Results{
resultsCurr = current'
, resultsStep = succ resultsStep
, resultsHist = current : resultsHist
}
-- | Get the 'TVar' for the specified step, blocking until that step is reached
--
-- This is executed by each client on each step. As a result, we can assume that
-- the required step can never be /before/ the current step (because all clients
-- must deliver their result for the current step before the step advances).
waitForStep :: TVar Results -> Int -> IO (TVar [StepResult])
waitForStep results step = atomically $ do
Results{resultsCurr, resultsStep} <- readTVar results
if resultsStep < step
then retry
else return resultsCurr
-- | Wait until a history of at least the specified length is ready
waitForHistoryOfMinLen :: TVar Results -> Int -> IO [[StepResult]]
waitForHistoryOfMinLen results numSteps = atomically $ do
Results{resultsHist} <- readTVar results
if length resultsHist < numSteps
then retry
else return resultsHist
inLockstep :: forall rpc a.
TrivialRpc rpc
=> Client.Connection -- ^ Server to connect to
-> Proxy rpc -- ^ Method to call
-> Int -- ^ Number of clients
-> NumSteps -- ^ How many steps each client should take
-> (TVar Results -> IO [[StepResult]] -> IO a)
-- ^ Monitor the results
--
-- This is also passed a function to get the /final/ results, after all
-- clients have terminated. If some clients never terminate, this function
-- will block indefinitely.
-> IO a
inLockstep conn rpc numClients numSteps monitor = do
results <- initResults
withAsync (collectResults numClients results) $ \_ ->
withAsync (runClients results) $ \clients ->
monitor results (wait clients)
where
runClients :: TVar Results -> IO [[StepResult]]
runClients results = do
replicateConcurrently_ numClients $
Client.withRPC conn def rpc (client results)
resultsHist <$> readTVarIO results
client :: TVar Results -> Client.Call rpc -> IO ()
client results call = loop 1
where
loop :: Int -> IO ()
loop n = do
current <- waitForStep results n
handle (recordException current) $
case numSteps of
TerminateAfter n' | n == n' -> do
Binary.sendFinalInput call n
(resp, NoMetadata) <- Binary.recvFinalOutput call
atomically $ modifyTVar current (StepOk resp:)
_otherwise -> do
Binary.sendNextInput call n
resp <- Binary.recvNextOutput call
atomically $ modifyTVar current (StepOk resp:)
loop (succ n)
recordException :: TVar [StepResult] -> SomeException -> IO ()
recordException current e =
atomically $ modifyTVar current (StepFailed e:)
{-------------------------------------------------------------------------------
Auxiliary: trivial RPCs
We want two distinct handler so we have two trivial RPCs.
-------------------------------------------------------------------------------}
type TrivialRpc rpc = (
SupportsClientRpc rpc
, Input rpc ~ Lazy.ByteString
, Output rpc ~ Lazy.ByteString
, RequestMetadata rpc ~ NoMetadata
, ResponseInitialMetadata rpc ~ NoMetadata
, ResponseTrailingMetadata rpc ~ NoMetadata
)
type RPC1 = Trivial' "rpc1"
type RPC2 = Trivial' "rpc2"