packages feed

grapesy-1.2.2: test-disconnect/Test/Disconnect/Keepalive.hs

module Test.Disconnect.Keepalive (
    test_keepAlivePreventsIdleClose
  , test_idleTimeoutWithoutKeepAliveDisconnects
  ) where

import Control.Concurrent
import Control.Exception (SomeException, fromException, try)
import Data.Proxy
import Data.Word
import Network.HTTP2.Client (HTTP2Error(..))
import Network.Socket (PortNumber)
import Test.HUnit

import Network.GRPC.Common
import Network.GRPC.Client        qualified as Client
import Network.GRPC.Client.Binary qualified as Client.Binary
import Network.GRPC.Server        qualified as Server

import Network.GRPC.Server.Run qualified as Grapesy

import Test.Disconnect.Echo.RPC
import Test.Disconnect.Echo.Server

{-------------------------------------------------------------------------------
  Test client idle timeout / keepalive ping

  'http2' closes a connection once idle for 'http2ClientIdleTimeout' (default
  30s); a keepalive ping ('http2ClientKeepAlivePingInterval') resets that
  timer even when no RPCs are in flight.

  We use a short idle timeout so the tests run quickly, and check both
  directions: a keepalive ping keeps an otherwise-silent connection alive
  past the timeout, and without one the connection really is closed (with
  @http2@'s own 'ConnectionIsTimeout') -- proving the first case isn't
  trivially true just because nothing ever closes idle connections.
-------------------------------------------------------------------------------}

-- | How long the client is configured to tolerate a silent connection
idleTimeout :: Int
idleTimeout = 500_000 -- 500ms

-- | Keepalive ping interval, comfortably below 'idleTimeout'
keepAlivePingInterval :: Int
keepAlivePingInterval = 100_000 -- 100ms

-- | How long the test stays silent before attempting another RPC
--
-- Comfortably past 'idleTimeout', so an unkept-alive connection is
-- guaranteed to have been closed by @http2@ by the time we check.
silence :: Int
silence = 2_000_000 -- 2s

test_keepAlivePreventsIdleClose :: Assertion
test_keepAlivePreventsIdleClose =
    withServer $ \serverAddr ->
      withKeepAlive (Just keepAlivePingInterval) serverAddr $ \conn -> do
        threadDelay silence
        result <- tryEcho conn
        assertBool ("expected successful echo, got " ++ show result) $
          case result of
            Right 42   -> True
            _otherwise -> False

test_idleTimeoutWithoutKeepAliveDisconnects :: Assertion
test_idleTimeoutWithoutKeepAliveDisconnects =
    withServer $ \serverAddr ->
      withKeepAlive Nothing serverAddr $ \conn -> do
        threadDelay silence
        result <- tryEcho conn
        assertBool ("expected ConnectionIsTimeout, got " ++ show result) $
          case result of
            Left e | Just ConnectionIsTimeout <- fromException e -> True
            _otherwise -> False

{-------------------------------------------------------------------------------
  Util
-------------------------------------------------------------------------------}

-- | Connect with 'idleTimeout' and the given keepalive ping interval
withKeepAlive ::
     Maybe Int -- ^ 'http2ClientKeepAlivePingInterval'
  -> Client.Server
  -> (Client.Connection -> IO a)
  -> IO a
withKeepAlive pingInterval = Client.withConnection connParams
  where
    connParams = def {
        Client.connHTTP2Settings = def {
            http2ClientIdleTimeout           = Just idleTimeout
          , http2ClientKeepAlivePingInterval = pingInterval
          }
      }

-- | Run one echo round-trip, properly closing the stream
--
-- Leaving 'withRPC''s scope with the request stream still open is itself
-- reported as a client-side 'GrpcCancelled' -- indistinguishable from the
-- failure we're testing for -- so we close it ('sendEndOfInput') and
-- consume the trailers ourselves.
tryEcho :: Client.Connection -> IO (Either SomeException Word64)
tryEcho conn = try $
    Client.withRPC conn def (Proxy @Echo1) $ \call -> do
      Client.Binary.sendNextInput @Word64 call 42
      resp <- Client.Binary.recvNextOutput @Word64 call
      Client.sendEndOfInput call
      NoMetadata <- Client.recvTrailers call
      return resp

withServer :: (Client.Server -> IO a) -> IO a
withServer k = do
    let handler = Server.someRpcHandler $
                    Server.mkRpcHandler @Echo1 (handleEcho (Proxy @Echo1))
    server <- Server.mkGrpcServer def [handler]
    Grapesy.forkServer def serverConfig server $ \runningServer -> do
      serverPort <- Grapesy.getServerPort runningServer
      k $ mkServerAddress serverPort
  where
    serverConfig :: Grapesy.ServerConfig
    serverConfig = Grapesy.ServerConfig {
          serverInsecure = Just $ Grapesy.InsecureConfig {
              insecureHost = Just "127.0.0.1"
            , insecurePort = 0
            }
        , serverSecure = Nothing
        }

    mkServerAddress :: PortNumber -> Client.Server
    mkServerAddress port = Client.ServerInsecure Client.Address {
          addressHost      = "127.0.0.1"
        , addressPort      = port
        , addressAuthority = Nothing
        }