packages feed

grapesy-1.2.0: src/Network/GRPC/Util/Session/Client.hs

-- | Node with client role (i.e., its peer is a server)
module Network.GRPC.Util.Session.Client (
    ConnectionToServer(..)
  , NoTrailers(..)
  , CancelRequest
  , setupRequestChannel
  ) where

import Network.GRPC.Util.Imports

import Control.Concurrent.STM qualified as STM
import Data.ByteString qualified as BS.Strict
import Data.ByteString qualified as Strict (ByteString)
import Data.ByteString.Lazy qualified as BS.Lazy
import Data.ByteString.Lazy qualified as Lazy (ByteString)
import Network.HTTP.Semantics qualified as HTTP
import Network.HTTP.Semantics.Client qualified as Client
import Network.HTTP.Types qualified as HTTP

import Network.GRPC.Common.Exception
import Network.GRPC.Util.ClientStream
import Network.GRPC.Util.HeaderTable (fromHeaderTable)
import Network.GRPC.Util.RedundantConstraint (addConstraint)
import Network.GRPC.Util.Session.API
import Network.GRPC.Util.Session.Channel
import Network.GRPC.Util.Stream
import Network.GRPC.Util.Thread

{-------------------------------------------------------------------------------
  Connection
-------------------------------------------------------------------------------}

-- | Connection to the server, as provided by @http2@
data ConnectionToServer = ConnectionToServer {
      sendRequest :: forall a.
           Client.Request
        -> (Client.Response -> IO a)
        -> IO a
    }

{-------------------------------------------------------------------------------
  Initiate request

  Control flow and exception handling here is a little tricky.

  * 'sendRequest' (coming from @http2@) will itself spawn a separate thread to
    deal with sending inputs to the server (here, that is 'sendMessageLoop').

  * As the last thing it does, 'sendRequest' will then call its continuation
    argument in whatever thread it was called. Here, that continuation argument
    is 'recvMessageLoop' (dealing with outputs sent by the server), which we
    want to run in a separate thread also. We must therefore call 'sendRequest'
    in a newly forked thread.

    Note that 'sendRequest' will /only/ call its continuation once it receives
    a response from the server. Some servers will not send the (start of the)
    response until it has received (part of) the request, so it is important
    that we do not wait on 'sendRequest' before we return control to the caller.

  * We need to decide what to do with any exceptions that might arise in these
    threads. One option might be to rethrow those exceptions to the parent
    thread, but this presupposes a certain level of hygiene in client code: if
    the client code spawns threads of their own, and shares the open RPC call
    between them, then we rely on the client code to propagate the exception
    further.

    We therefore choose a different approach: we do not try to propagate the
    exception at all, but instead ensure that any (current or future) call to
    'read' or 'write' (or 'push') on the open 'Peer' will result in an exception
    when the respective threads have died.

  * This leaves one final problem to deal with: if setting up the request
    /itself/ throws an exception, one or both threads might never get started.
    To avoid client code blocking indefinitely we will therefore catch any
    exceptions that arise during the call setup, and use them to 'cancel' both
    threads (which will kill them or ensure they never get started).
-------------------------------------------------------------------------------}

-- | No trailers
--
-- We do not support outbound trailers in the client. This simplifies control
-- flow: when the inbound stream closes (because the server terminates the RPC),
-- we kill the outbound thread; by not supporting outbound trailers, we avoid
-- that exception propagating to the trailers maker, which causes http2 to
-- panic and shut down the entire connection.
class NoTrailers sess where
  -- | There is no interesting information in the trailers
  noTrailers :: Proxy sess -> Trailers (Outbound sess)

type CancelRequest = Maybe ExactException -> IO ()

type InboundResult sess =
       Either (NoMessages (Inbound sess))
              (Trailers   (Inbound sess))

-- | Setup request channel
--
-- This initiates a new request.
--
setupRequestChannel :: forall sess.
     (HasCallStack, InitiateSession sess, NoTrailers sess)
  => sess
  -> ConnectionToServer
  -> (InboundResult sess -> ExactException)
  -- ^ We assume that when the server closes their outbound connection to us,
  -- the entire conversation is over (i.e., the server cannot "half-close").
  -> FlowStart (Outbound sess)
  -> IO (Channel sess, CancelRequest)
setupRequestChannel sess conn terminateCall outboundStart = do
    channel <- initChannel "client"
    monitorInbound terminateCall channel
    let requestInfo = buildRequestInfo sess outboundStart

    cancelRequestVar <- STM.newEmptyTMVarIO
    let cancelRequest :: CancelRequest
        cancelRequest e = do
            -- If the outbound thread died, cancelRequestVar might never be set
            cancel <- withThreadInterface (channelOutbound channel) $ \_ ->
                         STM.readTMVar cancelRequestVar
            cancel e

    case outboundStart of
      FlowStartRegular headers -> do
        regular <- initFlowStateRegular headers
        let req :: Client.Request
            req = Client.requestStreamingIface
                    (requestMethod  requestInfo)
                    (requestPath    requestInfo)
                    (requestHeaders requestInfo)
                $ outboundThread channel cancelRequestVar regular
        forkRequest channel req
      FlowStartNoMessages trailers -> do
        let req :: Client.Request
            req = Client.requestNoBody
                    (requestMethod  requestInfo)
                    (requestPath    requestInfo)
                    (requestHeaders requestInfo)
        atomically $ do
          -- Can't cancel non-streaming request
          STM.putTMVar cancelRequestVar $ \_ -> return ()
          STM.modifyTVar (channelOutbound channel) $ \oldState ->
            case oldState of
              ThreadNotStarted debugId ->
                ThreadTrivial debugId trailers
              _otherwise ->
                error "setupRequestChannel: expected thread state"
        forkRequest channel req

    return (channel, cancelRequest)
  where
    _ = addConstraint @(NoTrailers sess)

    forkRequest :: Channel sess -> Client.Request -> IO ()
    forkRequest channel req =
        forkThread "grapesy:clientInbound" (channelInbound channel) $ \unmask ctxt -> unmask $
          sendRequest conn req $ \resp -> do
            responseStatus <-
              case Client.responseStatus resp of
                Just x  -> return x
                Nothing -> throwIO PeerMissingPseudoHeaderStatus

            -- Read the entire response body in case of a non-OK response
            responseBody :: Maybe Lazy.ByteString <-
              if HTTP.statusIsSuccessful responseStatus then
                return Nothing
              else
                Just <$> readResponseBody resp

            let responseHeaders =
                  fromHeaderTable $ Client.responseHeaders resp
                responseInfo = ResponseInfo {
                  responseHeaders
                , responseStatus
                , responseBody
                }

            flowStart <- parseResponse sess responseInfo
            case flowStart of
              FlowStartRegular headers -> do
                regular <- initFlowStateRegular headers
                stream  <- clientInputStream resp
                threadMainBody ctxt regular $ \markDone ->
                  markDone =<< recvMessageLoop sess regular stream
              FlowStartNoMessages trailers -> do
                threadTrivial ctxt trailers

    outboundThread ::
         Channel sess
      -> STM.TMVar CancelRequest
      -> RegularFlowState (Outbound sess)
      -> HTTP.OutBodyIface
      -> IO ()
    outboundThread channel cancelRequestVar regular iface =
        threadBody "grapesy:clientOutbound" (channelOutbound channel) $ \ctxt -> do
          threadMainBody ctxt regular $ \markDone -> do
            atomically $ STM.putTMVar cancelRequestVar cancelRequest
            stream   <- clientOutputStream iface
            -- Unlike the client inbound thread, or the inbound/outbound threads
            -- of the server, http2 knows about this particular thread and may
            -- raise an exception on it when the server dies. This results in a
            -- race condition between that exception and the exception we get from
            -- attempting to read the next message. No matter who wins that race,
            -- we need to mark that as 'ServerDisconnected'.
            --
            -- We don't have this top-level exception handler in other places
            -- because we don't want to mark /our own/ exceptions as
            -- 'ServerDisconnected' or 'ClientDisconnected'.
            wrapServerDisconnected $
              HTTP.outBodyUnmask iface $
                sendMessageLoop sess regular stream markDone
      where
        cancelRequest :: CancelRequest
        cancelRequest = HTTP.outBodyCancel iface . fmap unwrapExactException

-- | Make outbound thread monitor the input thread
--
-- The outbound thread is started by http2 when the request is successfully
-- initiated from the inbound thread. This means that monitoring must be setup
-- /outside of/ the outbound thread, because if not the monitor might never be
-- setup, and an attempt to interact with the outbound thread might block
-- indefinitely because it never makes it past its 'ThreadNotStarted' not
-- started state, even though the inbound thread has died.
--
-- In HTTP2, streams are bidirectional and can be half-closed in either
-- direction. However, in gRPC the stream can be half-closed from the client to
-- the server (indicating that the client will not send any more messages), but
-- not from the server to the client: when the server half-closes their
-- connection, it sends the gRPC trailers and this terminates the call.
monitorInbound :: forall sess.
    (InboundResult sess -> ExactException)
 -> Channel sess -> IO ()
monitorInbound terminateCall channel = do
    _monitorRef <-
      threadMonitor
        (channelOutbound channel)
        (channelInbound channel)
        monitorPred
    return ()
  where
    monitorPred ::
          Either
            ThreadException
            ( Either
                (NoMessages (Inbound sess))
                (Trailers   (Inbound sess))
            )
       -> Maybe ExactException
    monitorPred = \case
        Left  e        -> Just $ threadException e
        Right trailers -> Just $ terminateCall trailers


{-------------------------------------------------------------------------------
   Auxiliary http2
-------------------------------------------------------------------------------}

readResponseBody :: Client.Response -> IO Lazy.ByteString
readResponseBody resp = go []
  where
    go :: [Strict.ByteString] -> IO Lazy.ByteString
    go acc = do
        chunk <- Client.getResponseBodyChunk resp
        if BS.Strict.null chunk then
          return $ BS.Lazy.fromChunks (reverse acc)
        else
          go (chunk:acc)