packages feed

grapesy-1.2.1: test-grapesy/Test/Sanity/Trailers.hs

{-# OPTIONS_GHC -Wno-orphans #-}

module Test.Sanity.Trailers (tests) where

import Control.Monad
import Data.Binary (Binary)
import Data.ByteString qualified as BSS
import Data.List qualified as List
import Data.String
import GHC.Generics
import Test.Tasty
import Test.Tasty.HUnit
import Text.Printf

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

import Test.Driver.ClientServer

{-------------------------------------------------------------------------------
  Testcases

  These are primarily tests of the underlying http2 substrate. We test for
  three regressions specifically:

  A. HPACK desync on reset streams. A header block arrives for a stream we've
     already removed from the stream table; getStream returns Nothing and
     controlOrStream's otherwise -> return () drops it instead of feeding it to
     the decoder. Connection-global table, so everything after is wrong.

  B. Trailers spanning CONTINUATION can't be received. The trailers clause in
     stream tests endOfStream and never endOfHeader, so it decodes only the
     first fragment.

  C. A single field line larger than the frame payload can't be sent. The
     encoder splits at header boundaries, so an oversized field yields "cannot
     compress the header" as a connection error.
-------------------------------------------------------------------------------}

tests :: TestTree
tests = testGroup "Trailers" [
      testCase (testName params) $ testWithParams params
    | params <- testParams
    ]

data TestParams = TestParams{
      paramsNumCalls    :: Int
    , paramsNumTrailers :: Int
    , paramsTrailerSize :: Int
    }

testParams :: [TestParams]
testParams = [
      -- Controls: everything fits in one frame. Should pass today.
      TestParams 1 2   1000   -- ~2706
    , TestParams 3 2   1000   -- same, 3 calls: is the multi-call harness itself sound?
    , TestParams 1 20  100    -- ~3060, many small — control for the block below

      -- Bracket the CONTINUATION threshold (~1525 at n=2)
    , TestParams 1 2   1400   -- ~3770, one frame  -> pass
    , TestParams 1 2   1600   -- ~4300, two frames -> B

      -- Bug B, unambiguous
    , TestParams 1 2   2000   -- silently loses trailer1

      -- Bracket the B/C threshold (~3050)
    , TestParams 1 2   2900   -- field ~3886 < 4087 -> B
    , TestParams 1 2   3200   -- field ~4286 > 4087 -> C

      -- Bug C, unambiguous
    , TestParams 1 2   4000   -- "cannot compress the header"

      -- Cross-call desync: entries small enough to persist (~181 each, table holds ~22)
    , TestParams 1 40  100    -- ~6120, 2 frames; loses ~14 trailers silently
    , TestParams 2 40  100    -- the payoff: call 2 resolves indices against a wrong table
    , TestParams 3 40  100    -- does it compound, or error?
    , TestParams 2 80  100    -- ~12240, 3 frames; more divergence

      -- Post-fix only: exercises multi-frame reassembly properly
    , TestParams 1 200 100    -- ~30600, 8 frames — under continuationLimit (10)
    ]

{-------------------------------------------------------------------------------
  Test output
-------------------------------------------------------------------------------}

testName :: TestParams -> TestName
testName params = List.intercalate "." . map show $ [
      paramsNumCalls    params
    , paramsNumTrailers params
    , paramsTrailerSize params
    ]

type Error = String

-- | Compare received trailers against expected
--
-- We deliberately avoid @show@ing the trailers: at the top end of 'testParams'
-- that is tens of kilobytes of escaped bytes. Instead we exploit the fact that
-- 'mkTrailers' fills each value with the trailer's own index, so @"100 x 17"@
-- both describes the value and identifies which trailer it came from. A desync
-- then reads directly as @expected "100 x 17", got "100 x 21"@.
checkTrailers :: Int -> [CustomMetadata] -> [CustomMetadata] -> [Error]
checkTrailers callIx expected actual = concat [
      [ inCall $ concat [
            "number of trailers: "
          , "expected " , show (length expected)
          , ", got "    , show (length actual)
          ]
      | length expected /= length actual
      ]

    , [ inCall $ concat [
            "trailer "    , show i
          , ": expected " , describe e
          , ", got "      , describe a
          ]
      | (i, e, a) <- zip3 [0 :: Int ..] expected actual
      , e /= a
      ]
    ]
  where
    inCall :: String -> String
    inCall msg = "call " ++ show callIx ++ ": " ++ msg

    describe :: CustomMetadata -> String
    describe md = concat [
          show (customMetadataName md), " = "
        , describeValue (customMetadataValue md)
        ]

    describeValue :: BSS.ByteString -> String
    describeValue bs =
        case BSS.uncons bs of
          Nothing -> "<empty>"
          Just (b, rest)
            | BSS.all (== b) rest -> show (BSS.length bs) ++ " x " ++ show b
            | otherwise           -> show (BSS.length bs) ++ " bytes (mixed)"

{-------------------------------------------------------------------------------
  Test proper / gRPC client
-------------------------------------------------------------------------------}

testWithParams :: TestParams -> Assertion
testWithParams params = testClientServer ClientServerTest{
      config = def
    , server = [Server.someRpcHandler @TestRpc sendTrailers]
    , client = simpleTestClient $ \conn -> do
        errs <- fmap concat $ forM [0 .. paramsNumCalls params - 1] $ \callIx -> do
          Client.withRPC conn def (Proxy @TestRpc) $ \call -> do
            Client.Binary.sendFinalInput call serverParams
            ((), actual) <- Client.Binary.recvFinalOutput call
            return $ checkTrailers callIx (mkTrailers serverParams) actual
        unless (null errs) $ assertFailure $ List.intercalate "\n" errs
    }
  where
    serverParams :: ServerParams
    serverParams = ServerParams{
          serverNumTrailers = paramsNumTrailers params
        , serverTrailerSize = paramsTrailerSize params
        }

{-------------------------------------------------------------------------------
  Server handler
-------------------------------------------------------------------------------}

type TestRpc = RawRpc "TestTrailers" "Test"

type instance RequestMetadata          TestRpc = [CustomMetadata]
type instance ResponseInitialMetadata  TestRpc = [CustomMetadata]
type instance ResponseTrailingMetadata TestRpc = [CustomMetadata]

data ServerParams = ServerParams{
      serverNumTrailers :: Int
    , serverTrailerSize :: Int
    }
  deriving stock (Show, Eq, Generic)
  deriving anyclass (Binary)

sendTrailers :: Server.RpcHandler IO TestRpc
sendTrailers = Server.mkRpcHandlerNoDefMetadata $ \call -> do
    params <- Server.Binary.recvFinalInput call
    let trailers = mkTrailers params

    -- We do /not/ announce the trailers ahead of time.
    --
    -- The @Trailer@ header is optional, and we skip it here: with many trailers,
    -- it would /itself/ get large, which would confound the test.
    Server.setResponseInitialMetadataAndTrailers call [] . Just $
      map customMetadataName trailers

    -- Send the trailers proper
    Server.Binary.sendFinalOutput @() call ((), trailers)

mkTrailers :: ServerParams -> [CustomMetadata]
mkTrailers params = [
      metadata i (serverTrailerSize params)
    | i <- [0 .. serverNumTrailers params - 1]
    ]
  where
    metadata :: Int -> Int -> CustomMetadata
    metadata i sz =
        CustomMetadata
          (fromString $ "trailer" ++ printf "%03d" i ++ "-bin")
          (BSS.pack . replicate sz $ fromIntegral i)