packages feed

grapesy-1.2.0: src/Network/GRPC/Server/Handler.hs

-- | RPC handlers
--
-- Intended for unqualified import.
module Network.GRPC.Server.Handler (
    RpcHandler(..)
  , hoistRpcHandler
    -- * Construction
  , mkRpcHandler
  , mkRpcHandlerNoDefMetadata
    -- * Hide type argument
  , SomeRpcHandler(..)
  , someRpcHandler
  , hoistSomeRpcHandler
    -- * Execution
  , runHandler
  ) where

import Control.Exception qualified as E
import System.ThreadManager (KilledByThreadManager(..))

import Network.GRPC.Common.Exception
import Network.GRPC.Server.Call
import Network.GRPC.Server.Context
import Network.GRPC.Util.GHC
import Network.GRPC.Util.Imports
import Network.GRPC.Util.Session.Channel qualified as Session

{-------------------------------------------------------------------------------
  Handlers

  This is essentially an untyped interface; for a typed layer, see
  "Network.GRPC.Server.Protobuf".
-------------------------------------------------------------------------------}

-- | Handler for an RPC request
--
-- To construct an 'RpcHandler', you have two options:
--
-- * Use the \"raw\" API by calling 'mkRpcHandler'; this gives you full control
--   over the interaction with the client.
-- * Use the API from "Network.GRPC.Server.StreamType" to define handlers that
--   use the Protobuf stream types. This API is more convenient, and can be used
--   to guarantee at compile-time  that you have a handler for every method of
--   the services you support, but provides less flexibility (although it offers
--   an \"escape\" to the full API through
--   'Network.GRPC.Server.StreamType.RawMethod').
--
-- __Note on cancellation.__ The GRPC spec allows clients to \"cancel\" a
-- request (<https://grpc.io/docs/guides/cancellation/>). This does not
-- correspond to any specific message being sent across the network; instead,
-- the client simply disappears. The spec is quite clear that it is the
-- responsibility of the handler /itself/ to monitor for this. In @grapesy@ this
-- works as follows:
--
-- * Handlers are /not/ terminated when a client disappears. This allows the
--   handler to finish what it's doing, and terminate cleanly.
-- * When a handler tries to receive a message from the client ('recvInput'), or
--   send a message to the client ('sendOutput'), and the client disappeared,
--   this will result in a 'Network.GRPC.Server.ClientDisconnected' exception,
--   which the handler can catch and deal with.
--
-- Cancellation is always at the request of the /client/. If the /handler/
-- terminates early (that is, before sending the final output and trailers), a
-- 'Network.GRPC.Server.HandlerTerminated' exception will be raised and sent to
-- the client as 'GrpcException' with 'GrpcUnknown' error code.
data RpcHandler (m :: Type -> Type) (rpc :: k) = RpcHandler {
      -- | Handler proper
      runRpcHandler_ :: HasCallStack => Call rpc -> m ()
    }

runRpcHandler :: HasCallStack => RpcHandler m rpc -> Call rpc -> m ()
runRpcHandler RpcHandler{runRpcHandler_} = runRpcHandler_

-- | Hoist an 'RpcHandler' to a different monad
--
-- We do not make 'RpcHandler' an instance of @MFunctor@ (from the @mmorph@
-- package) because @RpcHandler m@ is not a monad; this means that even though
-- the types line up, the concepts do not.
hoistRpcHandler ::
     (forall a. m a -> n a)
  -> RpcHandler m rpc
  -> RpcHandler n rpc
hoistRpcHandler f (RpcHandler h) = RpcHandler (f . h)

{-------------------------------------------------------------------------------
  Construction
-------------------------------------------------------------------------------}

-- | Constructor for 'RpcHandler'
--
-- When the handler sends its first message to the client, @grapesy@ must first
-- send the initial metadata (of type 'ResponseInitialMetadata') to the client.
-- This metadata can be updated at any point before that first message (for
-- example, after receiving some messages from the client) by calling
-- 'setResponseInitialMetadata'. If this function is never called, however, then
-- we need a default value; 'mkRpcHandler' therefore calls
-- 'setResponseInitialMetadata' once before the handler proper, relying on the
-- 'Default' instance.
--
-- For RPCs where a sensible default does not exist (perhaps the initial
-- response metadata needs the request metadata from the client, or even some
-- messages from the client), you can use 'mkRpcHandlerNoDefMetadata'.
mkRpcHandler ::
     ( Default        (ResponseInitialMetadata  rpc)
     , StaticMetadata (ResponseTrailingMetadata rpc)
     , MonadIO m
     )
  => (HasCallStack => Call rpc -> m ())
  -> RpcHandler m rpc
mkRpcHandler k = RpcHandler $ \call -> do
    liftIO $ setResponseInitialMetadata call def
    k call

-- | Variant on 'mkRpcHandler' that does not call 'setResponseInitialMetadata'
--
-- You /must/ call 'setResponseInitialMetadata' before sending the first
-- message. See 'mkRpcHandler' for additional discussion.
mkRpcHandlerNoDefMetadata :: (Call rpc -> m ()) -> RpcHandler m rpc
mkRpcHandlerNoDefMetadata = RpcHandler

{-------------------------------------------------------------------------------
  Hide the type argument
-------------------------------------------------------------------------------}

-- | Wrapper around 'RpcHandler' that hides the type argument
--
-- Construct using 'someRpcHandler'.
data SomeRpcHandler m = forall rpc.
     SupportsServerRpc rpc
  => SomeRpcHandler (Proxy rpc) (RpcHandler m rpc)

-- | Constructor for 'SomeRpcHandler'
someRpcHandler :: forall rpc m.
     SupportsServerRpc rpc
  => RpcHandler m rpc -> SomeRpcHandler m
someRpcHandler = SomeRpcHandler Proxy

hoistSomeRpcHandler ::
     (forall a. m a -> n a)
  -> SomeRpcHandler m
  -> SomeRpcHandler n
hoistSomeRpcHandler f (SomeRpcHandler p h) =
    SomeRpcHandler p (hoistRpcHandler f h)

{-------------------------------------------------------------------------------
  Execution
-------------------------------------------------------------------------------}

-- | Accept incoming call
--
-- If the handler throws an exception, we will /attempt/ to inform the client
-- of what happened (see 'forwardException') before re-throwing the exception.
runHandler :: forall rpc.
     HasCallStack
  => (forall x. IO x -> IO x)
  -> Call rpc
  -> RpcHandler IO rpc
  -> IO ()
runHandler unmask call handler = do
    -- http2 will kill the handler when the client disappears, but we want the
    -- handler to be able to terminate cleanly. We therefore run the handler in
    -- a separate thread, and wait for that thread to terminate.
    handlerThread <- asyncLabelled "grapesy:handler" handler'
    waitForHandler unmask call handlerThread
  where
    -- The handler itself will run in a separate thread
    handler' :: IO ()
    handler' = do
        result <- tryExact $ runRpcHandler handler call
        handlerTeardown result

    -- Deal with any exceptions thrown in the handler
    handlerTeardown :: Either ExactException () -> IO ()
    handlerTeardown (Right ()) = do
        -- Handler terminated successfully, but may not have sent final message.
        -- /If/ the final message was sent, 'forwardException' does nothing.
        forwarded <- forwardException call . WrapExactException $
                        toException HandlerTerminated
        ignoreUncleanClose call $ ExitCaseSuccess ()
        when forwarded $
          -- The handler terminated before it sent the final message.
          throwIO HandlerTerminated
    handlerTeardown (Left err) = do
        -- The handler threw an exception. Attempt to tell the client.
        _forwarded <- forwardException call err
        ignoreUncleanClose call $ ExitCaseException (unwrapExactException err)
        throwExact err

-- | Close the connection to the client, ignoring errors
--
-- An unclean shutdown can have 2 causes:
--
-- 1. We lost communication during the call.
--
--    We have no way of telling the client that something went wrong.
--
-- 2. The handler failed to properly terminate the communication
--    (send the final message and call 'waitForOutbound').
--
--    This is a bug in the handler, and is trickier to deal with. We don't
--    really know what state the handler left the channel in; for example,
--    we might have killed the thread halfway through sending a message.
--
--    So there not really anything we can do here (except perhaps show
--    the exception in 'serverTopLevel').
ignoreUncleanClose :: Call rpc -> ExitCase a -> IO ()
ignoreUncleanClose Call{callChannel} reason =
    Session.close callChannel reason

-- | Wait for the handler to terminate
--
-- If we are interrupted while we wait, it depends on the interruption:
--
-- * If we are interrupted by 'HTTP2.KilledByHttp2ThreadManager', it means we
--   got disconnected from the client. In this case, we shut down the channel
--   (if it's not already shut down); /if/ the handler at this tries to
--   communicate with the client, an exception will be raised. However, the
--   handler /can/ terminate cleanly and, of course, typically will.
--   Importantly, this avoids race conditions: even if the server and the client
--   agree that no further communication takes place (the client sent their
--   final message, the server sent the trailers), without the indireciton of
--   this additional thread it can still happen that http2 kills the handler
--   (after the client disconnects) before the handler has a chance to
--   terminate.
--
-- * If we are interrupted by another kind of asynchronous exception, we /do/
--   kill the handler (this might for example be a timeout).
--
-- This is in line with the overall design philosophy of communication in this
-- library: exceptions will only be raised synchronously when communication is
-- attempted, not asynchronously when we notice a problem.
waitForHandler ::
     HasCallStack
  => (forall x. IO x -> IO x)
  -> Call rpc -> Async () -> IO ()
waitForHandler unmask call handlerThread = loop
  where
    loop :: IO ()
    loop = do
         status <- waitAsyncStatus unmask handlerThread
         case status of
           AsyncDone () ->
             -- Handler terminated
             return ()
           AsyncFailed exact -> do
             -- Handler /itself/ failed
             throwExact exact
           WaitInterrupted exact@(WrapExactException se) ->
             -- /We/ received an exception whilst waiting for the handler
             --
             -- We now distinguish between two cases:
             --
             -- o If the exception we received was 'KilledByThreadManager',
             --   then this is http2 telling us the client disappeared. We leave
             --   the handler running, but mark the connection as broken. /If/
             --   the handler tries to communicate with the client, it will
             --   receive an exception at that point.
             -- o In all other cases, we cancel the handler. This might be when
             --   someone is shutting down the server, for example.
             case fromException se of
               Just (KilledByThreadManager mErr) -> do
                 backtrace <- collectBacktraces
                 let exitReason :: ExitCase ()
                     exitReason =
                       case mErr of
                         Nothing -> ExitCaseSuccess ()
                         Just _exitWithException ->
                           ExitCaseException . toException $
                             clientDisconnected backtrace exact
                 ignoreUncleanClose call exitReason
                 loop
               Nothing -> do
                 cancelWith handlerThread exact
                 throwExact exact

    clientDisconnected :: Backtraces -> ExactException -> ClientDisconnected
    clientDisconnected backtrace e = ClientDisconnected{
          clientDisconnectedException = e
        , clientDisconnectedBacktrace = Just backtrace
        }

-- | Process exception thrown by a handler
--
-- Trace the exception and forward it to the client.
--
-- The attempt to forward it to the client is a best-effort only:
--
-- * The nature of the exception might mean that we cannot send anything to
--   the client at all.
-- * It is possible the exception was thrown /after/ the handler already send
--   the trailers to the client.
--
-- We therefore catch and suppress all exceptions here. Returns @True@ if the
-- forwarding was successful, @False@ if it raised an exception.
forwardException :: HasCallStack => Call rpc -> ExactException -> IO Bool
forwardException call@Call{callContext} err = do
    trailers <- serverExceptionToClientError (serverParams callContext) err
    (True <$ sendProperTrailers call trailers) `catch` handler
 where
   handler :: ExactException -> IO Bool
   handler _e = return False

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

data AsyncStatus a =
   AsyncDone a
 | AsyncFailed ExactException
 | WaitInterrupted ExactException
 deriving stock (Show)

waitAsyncStatus ::
     HasCallStack
  => (forall x. IO x -> IO x)
  -> Async a -> IO (AsyncStatus a)
waitAsyncStatus unmask async =
    E.handle (return . WaitInterrupted) $
      either AsyncFailed AsyncDone <$>
        unmask (tryAgain $ atomically $ waitCatchExact async)
  where
    -- Ignore "blocked indefinitely" exceptions
    --
    -- This follows the implementation of `waitCatch` in async
    -- <https://github.com/simonmar/async/issues/14>.
    --
    -- See also blog post “When "blocked indefinitely" is not indefinite”
    -- <https://well-typed.com/blog/2024/01/when-blocked-indefinitely-is-not-indefinite/>.
    tryAgain :: forall x. IO x -> IO x
    tryAgain f = f `catch` \E.BlockedIndefinitelyOnSTM -> f