grapesy-1.0.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 Control.Concurrent
import Control.Concurrent.STM
import Control.Monad
import Control.Monad.Catch
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 Data.Proxy
import Network.HTTP.Types qualified as HTTP
import Network.HTTP2.Client qualified as Client
import Network.GRPC.Util.HTTP2 (fromHeaderTable)
import Network.GRPC.Util.HTTP2.Stream
import Network.GRPC.Util.RedundantConstraint (addConstraint)
import Network.GRPC.Util.Session.API
import Network.GRPC.Util.Session.Channel
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 SomeException -> IO ()
-- | Setup request channel
--
-- This initiates a new request.
--
setupRequestChannel :: forall sess.
(InitiateSession sess, NoTrailers sess)
=> sess
-> ConnectionToServer
-> (InboundResult sess -> SomeException)
-- ^ 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
ConnectionToServer{sendRequest}
terminateCall
outboundStart
= do
channel <- initChannel
let requestInfo = buildRequestInfo sess outboundStart
cancelRequestVar <- newEmptyMVar
let cancelRequest :: CancelRequest
cancelRequest e = join . (fmap ($ e)) $ readMVar cancelRequestVar
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 state :: FlowState (Outbound sess)
state = FlowStateNoMessages trailers
req :: Client.Request
req = Client.requestNoBody
(requestMethod requestInfo)
(requestPath requestInfo)
(requestHeaders requestInfo)
-- Can't cancel non-streaming request
putMVar cancelRequestVar $ \_ -> return ()
atomically $
modifyTVar (channelOutbound channel) $ \oldState ->
case oldState of
ThreadNotStarted debugId ->
ThreadDone debugId state
_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 markReady _debugId -> unmask $
linkOutboundToInbound (TerminateWhenInboundClosed terminateCall) channel $
sendRequest req $ \resp -> do
responseStatus <-
case Client.responseStatus resp of
Just x -> return x
Nothing -> throwM 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
state <- initFlowStateRegular headers
stream <- clientInputStream resp
markReady $ FlowStateRegular state
Right <$> recvMessageLoop sess state stream
FlowStartNoMessages trailers -> do
markReady $ FlowStateNoMessages trailers
return $ Left trailers
outboundThread ::
Channel sess
-> MVar CancelRequest
-> RegularFlowState (Outbound sess)
-> Client.OutBodyIface
-> IO ()
outboundThread channel cancelRequestVar regular iface =
threadBody "grapesy:clientOutbound" (channelOutbound channel) $ \markReady _debugId -> do
markReady $ FlowStateRegular regular
putMVar cancelRequestVar (Client.outBodyCancel iface)
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'.
wrapStreamExceptionsWith ServerDisconnected $
Client.outBodyUnmask iface $ sendMessageLoop sess regular stream
{-------------------------------------------------------------------------------
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)