packages feed

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

{-# OPTIONS_GHC -Wno-orphans #-}

-- | Tests for HTTP2 frames sent by the server after client sends RST_STREAM
--
-- Normally after a client sends RST_STREAM to a server, the server will not
-- send any more frames on that stream. However, some frames might already have
-- been put on the wire, or enqueued internally by the server, before it gets
-- the RST_STREAM, and so some frames might still arrive.
--
-- Such frames can be dropped, but only after their effect on the
-- connection-level state is taken into account:
--
-- * HPACK state must be updated for HEADERS and CONTINUATION frames
-- * Connection-level window size must be updated for DATA frames
--
-- In this module we use a scripted server ("Test.Util.FrameLevelServer") to
-- simulate some scenarios:
--
-- 1. Client opens a stream, sends its final input, then sends RST_STREAM,
--    after which the server responds with gRPC Trailers-Only headers. For
--    example, this can happen with a gRPC deadline on a unary call: the
--    deadline expires on both the client and the server at roughly the same
--    time, and the RST_STREAM/Trailers-Only cross each other.
-- 2. A variation on (1) where the server's trailers span two frames; the test
--    case is designed so that the two frames cannot be decoded separately but
--    must be decoded as one block.
-- 3. Variation on (1) where the server responds with an initial set of regular
--    headers (not trailers), which the client receives before sending its
--    final message and the RST_STREAM. This mimics cancellation after the
--    response has started, e.g. a deadline on a streaming call.
--
-- In all of these tests, the server is set up to explicitly wait for the
-- RST_STREAM before sending the frames under test; this is intended to mimic
-- the race condition described above, but in a deterministic way.
--
-- TODO: Also test for window size problems.
module Test.Sanity.FramesAfterReset (tests) where

import Control.Exception
import Data.Bits
import Data.ByteString.Char8 qualified as BS.Strict.Char8
import Data.ByteString.Lazy qualified as BS.Lazy
import Data.ByteString.Lazy qualified as Lazy (ByteString)
import Data.ByteString.Lazy.Char8 qualified as BS.Lazy.Char8
import Data.String
import Data.Word
import Test.Tasty
import Test.Tasty.HUnit

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

import Test.Util.FrameLevelServer (Script, Frame(..), FrameHeader(..))
import Test.Util.FrameLevelServer qualified as Frame

{-------------------------------------------------------------------------------
  List of tests
-------------------------------------------------------------------------------}

tests :: TestTree
tests = testGroup "FramesAfterReset" [
      testCase "trailersOnlySingleFrame" $
        test_framesAfterReset cancelBeforeHeaders ignoreRequest sendTrailersOnly
    , testCase "trailersOnlyTwoFrames" $
        test_framesAfterReset cancelBeforeHeaders ignoreRequest sendTrailersOnlyTwoFrames
    , testCase "trailersAfterHeaders" $
        test_framesAfterReset cancelAfterHeaders respondWithHeaders sendTrailers
    ]

{-------------------------------------------------------------------------------
  Tests proper
-------------------------------------------------------------------------------}

-- | Call 1 is cancelled, and the server sends a header block on stream 1 after
-- the reset. That block contains a dynamic table insertion, which call 2's
-- response references: it resolves only if the client decoded the late block.
test_framesAfterReset ::
     (Client.Call TestRpc -> IO ())  -- ^ Call 1 (leaving it sends RST_STREAM)
  -> Script ()                       -- ^ Server side of stream 1, before the reset
  -> Script ()                       -- ^ Late block on stream 1, after the reset
  -> Assertion
test_framesAfterReset call1 beforeReset sendLate =
    Frame.withScript Nothing (script beforeReset sendLate) $ \server getHandlerResults -> do
      trailers <- Client.withConnection def server $ \conn -> do
        expectCancelled $ Client.withRPC conn def (Proxy @TestRpc) call1
        Client.withRPC conn def (Proxy @TestRpc) $ \call -> do
          Client.sendFinalInput call BS.Lazy.empty
          Client.recvTrailers call
      assertBool "x-foo: bar in call 2's trailers" $
        CustomMetadata (fromString "x-foo") (BS.Strict.Char8.pack "bar") `elem` trailers
      results <- getHandlerResults
      assertEqual "handler results" [()] results

-- | Scenarios (1) and (2): send the final message, leave without reading
cancelBeforeHeaders :: Client.Call TestRpc -> IO ()
cancelBeforeHeaders call =
    Client.sendFinalInput call BS.Lazy.empty

-- | Scenario (3)
cancelAfterHeaders :: Client.Call TestRpc -> IO ()
cancelAfterHeaders call = do
    _ <- Client.recvResponseInitialMetadata call
    Client.sendFinalInput call BS.Lazy.empty

{-------------------------------------------------------------------------------
  Scripts
-------------------------------------------------------------------------------}

script :: Script () -> Script () -> Script ()
script beforeReset sendLate = do
    Frame.handshake
    beforeReset
    awaitResetAndRequest
    sendLate
    Frame.send $ Frame.mkFrame 0x1 0x5 3 probe  -- END_STREAM | END_HEADERS

-- | Wait for the reset of stream 1 and the end of the request on stream 3
--
-- These can arrive in either order: grapesy does not guarantee that call 1's
-- final message has reached http2 by the time the call is cancelled, and
-- depending on that, http2 sends the reset from one of two places. Either way
-- it removes the stream /before/ sending the reset, so once we have seen it,
-- the client has forgotten stream 1.
awaitResetAndRequest :: Script ()
awaitResetAndRequest = go False False
  where
    go :: Bool -> Bool -> Script ()
    go reset endOfRequest
      | reset && endOfRequest = return ()
      | otherwise = do
          (reset', endOfRequest') <- Frame.recv classify
          go (reset || reset') (endOfRequest || endOfRequest')

    classify :: Frame -> Either String (Bool, Bool)
    classify frame =
        case frameHeader frame of
          FrameHeader{frameType = 0x3, frameStreamId = 1} ->
            Right (True, False)
          FrameHeader{frameType, frameFlags, frameStreamId = 3}
            | frameType `elem` [0x0, 0x1] ->
            Right (False, testBit frameFlags 0)  -- END_STREAM
          _otherwise ->
            Left $ "Expected RST_STREAM on stream 1 or request on stream 3, got "
                ++ show frame

-- | Scenarios (1) and (2): skip the request on stream 1
ignoreRequest :: Script ()
ignoreRequest =
    Frame.ignore $ \frame ->
         Frame.defaultNoiseFilter frame
      || (   frameStreamId (frameHeader frame) == 1
          && frameType     (frameHeader frame) `elem` [0x0, 0x1]
         )

-- | Scenario (3): respond with initial headers, then skip the request message
respondWithHeaders :: Script ()
respondWithHeaders = do
    recvRequestHeaders 1
    Frame.send $ Frame.mkFrame 0x1 0x4 1 responseHeaders  -- END_HEADERS
    Frame.ignore $ \frame ->
         Frame.defaultNoiseFilter frame
      || (   frameStreamId (frameHeader frame) == 1
          && frameType     (frameHeader frame) == 0x0
         )

sendTrailersOnly :: Script ()
sendTrailersOnly =
    Frame.send $ Frame.mkFrame 0x1 0x5 1 lateTrailersOnly  -- END_STREAM | END_HEADERS

-- | Split inside the @x-foo@ literal, so that neither fragment decodes on its own
sendTrailersOnlyTwoFrames :: Script ()
sendTrailersOnlyTwoFrames = do
    Frame.send $ Frame.mkFrame 0x1 0x1 1 (BS.Lazy.take 5 lateTrailersOnly)  -- END_STREAM
    Frame.send $ Frame.mkFrame 0x9 0x4 1 (BS.Lazy.drop 5 lateTrailersOnly)  -- END_HEADERS

sendTrailers :: Script ()
sendTrailers =
    Frame.send $ Frame.mkFrame 0x1 0x5 1 lateTrailers  -- END_STREAM | END_HEADERS

recvRequestHeaders :: Word32 -> Script ()
recvRequestHeaders sid = Frame.recv $ \frame ->
    case frameHeader frame of
      FrameHeader{frameType = 0x1, frameStreamId}
        | frameStreamId == sid -> Right ()
      _otherwise -> Left $ "Expected HEADERS on stream " ++ show sid ++ ", got " ++ show frame

{-------------------------------------------------------------------------------
  Header blocks

  The late blocks are the only ones that insert into the client's dynamic table.
-------------------------------------------------------------------------------}

-- | Initial headers (scenario 3); no insertion
responseHeaders :: Lazy.ByteString
responseHeaders = status200 <> contentType

-- | Late Trailers-Only response (scenarios 1 and 2)
--
-- @x-foo@ comes first after @:status@, so that the split in
-- 'sendTrailersOnlyTwoFrames' falls inside it.
lateTrailersOnly :: Lazy.ByteString
lateTrailersOnly = status200 <> xFooInsert <> contentType <> grpcStatusOk

-- | Late trailers (scenario 3); no pseudo-headers
lateTrailers :: Lazy.ByteString
lateTrailers = xFooInsert <> grpcStatusOk

-- | Call 2's Trailers-Only response, referencing the late block's insertion
probe :: Lazy.ByteString
probe = status200 <> xFooIndexed <> contentType <> grpcStatusOk

status200 :: Lazy.ByteString
status200 = bytes [0x88]                             -- static index 8

contentType :: Lazy.ByteString
contentType = mconcat [
      bytes [0x00, 0x0c], ascii "content-type"      -- literal, no indexing, new name
    , bytes [0x14],       ascii "application/grpc+raw"
    ]

grpcStatusOk :: Lazy.ByteString
grpcStatusOk = mconcat [
      bytes [0x00, 0x0b], ascii "grpc-status"       -- literal, no indexing, new name
    , bytes [0x01],       ascii "0"
    ]

xFooInsert :: Lazy.ByteString
xFooInsert = mconcat [
      bytes [0x40, 0x05], ascii "x-foo"             -- literal, incremental indexing, new name
    , bytes [0x03],       ascii "bar"               --   -> dynamic index 62
    ]

xFooIndexed :: Lazy.ByteString
xFooIndexed = bytes [0xbe]                           -- indexed, dynamic index 62

{-------------------------------------------------------------------------------
  Test RPC
-------------------------------------------------------------------------------}

type TestRpc = RawRpc "FramesAfterReset" "ping"

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

{-------------------------------------------------------------------------------
  Internal auxiliary
-------------------------------------------------------------------------------}

bytes :: [Word8] -> Lazy.ByteString
bytes = BS.Lazy.pack

ascii :: String -> Lazy.ByteString
ascii = BS.Lazy.Char8.pack

expectCancelled :: IO () -> IO ()
expectCancelled =
    handleJust isCancelled return
  where
    isCancelled :: GrpcException -> Maybe ()
    isCancelled e = if grpcError e == GrpcCancelled then Just () else Nothing