capnp-0.17.0.0: tests/Module/Capnp/Rpc.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -Wno-error=missing-methods #-}
module Module.Capnp.Rpc (rpcTests) where
import Capnp
( Client,
IsCap,
Parse (..),
Pipeline,
SomeServer,
TypeParam,
Which,
callP,
callR,
createPure,
def,
defaultLimit,
evalLimitT,
export,
handleParsed,
lbsToMsg,
msgToParsed,
parsedToMsg,
pipe,
waitPipeline,
)
import Capnp.Bits (WordCount)
import Capnp.Gen.Aircraft hiding (Left, Right)
import Capnp.Gen.Capnp.Rpc
import qualified Capnp.Gen.Echo as E
import Capnp.Mutability (freeze)
import qualified Capnp.Pointer as P
import qualified Capnp.Repr as R
import Capnp.Rpc hiding (Client)
import Capnp.Rpc.Errors (eFailed)
import Capnp.Rpc.Revoke (makeRevocable)
import Capnp.Rpc.Untyped hiding (Client, Pipeline, export, waitPipeline)
import Control.Concurrent.Async (concurrently_, race_)
import Control.Concurrent.STM
import Control.Exception.Safe (SomeException, bracket, try)
import Control.Monad (replicateM, void, (>=>))
import Control.Monad.Catch (throwM)
import Control.Monad.IO.Class (liftIO)
import qualified Data.ByteString.Builder as BB
import Data.Foldable (for_)
import Data.Function ((&))
import Data.Functor ((<&>))
import qualified Data.Text as T
import Data.Traversable (for)
import Data.Word
import qualified Network.Socket as Socket
import qualified Supervisors
import System.Timeout (timeout)
import Test.Hspec
rpcTests :: Spec
rpcTests = do
echoTests
aircraftTests
unusualTests
-------------------------------------------------------------------------------
-- Tests using echo.capnp.
-------------------------------------------------------------------------------
echoTests :: Spec
echoTests =
describe "Echo server & client" $
it "Should echo back the same message." $
runVatPair
(\sup -> export @E.Echo sup TestEchoServer)
( \_sup echoSrv -> do
let msgs =
[ def {E.query = "Hello #1"},
def {E.query = "Hello #2"}
]
rets <- for msgs $ \arg ->
echoSrv
& callP #echo arg
>>= waitPipeline
>>= evalLimitT defaultLimit . parse
liftIO $
rets
`shouldBe` [ def {E.reply = "Hello #1"},
def {E.reply = "Hello #2"}
]
)
data TestEchoServer = TestEchoServer
instance SomeServer TestEchoServer
instance E.Echo'server_ TestEchoServer where
echo'echo _ = handleParsed $ \params -> pure def {E.reply = E.query params}
-------------------------------------------------------------------------------
-- Tests using aircraft.capnp.
--
-- These use the 'CallSequence' interface as a counter.
-------------------------------------------------------------------------------
-- | Bump a counter n times, returning a list of the results.
bumpN :: Client CallSequence -> Int -> IO [Parsed CallSequence'getNumber'results]
bumpN ctr n =
bumpNPipeline ctr n
>>= traverse waitPipeline
>>= traverse (evalLimitT defaultLimit . parse)
-- | Like 'bumpN', but doesn't wait for the results -- returns a list of pipelines.
bumpNPipeline :: Client CallSequence -> Int -> IO [Pipeline CallSequence'getNumber'results]
bumpNPipeline ctr n = replicateM n (ctr & callR #getNumber def)
aircraftTests :: Spec
aircraftTests = describe "aircraft.capnp rpc tests" $ do
describe "newPromiseClient" $
it "Should preserve E-order" $
Supervisors.withSupervisor $ \sup -> do
(pc, f) <- newPromiseClient
firsts <- bumpNPipeline pc 2
atomically (newTestCtr 0)
>>= export @CallSequence sup
>>= fulfill f
nexts <- bumpN pc 2
firstsResolved <-
for firsts $
waitPipeline >=> evalLimitT defaultLimit . parse
firstsResolved
`shouldBe` [ def {n = 1},
def {n = 2}
]
nexts
`shouldBe` [ def {n = 3},
def {n = 4}
]
it "Should propogate server-side exceptions to client method calls" $
runVatPair
(\sup -> export @CallSequence sup ExnCtrServer)
( \_sup ->
expectException
(callR #getNumber def)
def
{ type_ = Exception'Type'failed,
reason = "Something went sideways."
}
)
it "Should receive unimplemented when calling a method on a null cap." $
runVatPair
(\_sup -> pure (fromClient nullClient :: Client CallSequence))
( \_sup ->
expectException
(callR #getNumber def)
def
{ type_ = Exception'Type'unimplemented,
reason = "Method unimplemented"
}
)
it "Should throw an unimplemented exception if the server doesn't implement a method" $
runVatPair
(\sup -> export @CallSequence sup NoImplServer)
( \_sup ->
expectException
(callR #getNumber def)
def
{ type_ = Exception'Type'unimplemented,
reason = "Method unimplemented"
}
)
it "Should throw an opaque exception when the server throws a non-rpc exception" $
runVatPair
(\sup -> export @CallSequence sup NonRpcExnServer)
( \_sup ->
expectException
(callR #getNumber def)
def
{ type_ = Exception'Type'failed,
reason = "Unhandled exception"
}
)
it "A counter should maintain state" $
runVatPair
(\sup -> newTestCtr 0 >>= export @CallSequence sup)
( \_sup ctr -> do
results <- bumpN ctr 4
liftIO $
results
`shouldBe` [ def {n = 1},
def {n = 2},
def {n = 3},
def {n = 4}
]
)
it "Methods returning interfaces work" $
runVatPair
(\sup -> export @CounterFactory sup (TestCtrFactory sup))
( \_sup factory -> do
let newCounter start = do
CounterFactory'newCounter'results {counter} <-
factory
& callP #newCounter def {start}
>>= waitPipeline
>>= evalLimitT defaultLimit . parse
pure counter
ctrA <- newCounter 2
ctrB <- newCounter 0
r1 <- bumpN ctrA 4
liftIO $
r1
`shouldBe` [ def {n = 3},
def {n = 4},
def {n = 5},
def {n = 6}
]
r2 <- bumpN ctrB 2
liftIO $
r2
`shouldBe` [ def {n = 1},
def {n = 2}
]
ctrC <- newCounter 30
r3 <- bumpN ctrA 3
liftIO $
r3
`shouldBe` [ def {n = 7},
def {n = 8},
def {n = 9}
]
r4 <- bumpN ctrC 1
liftIO $ r4 `shouldBe` [def {n = 31}]
)
it "Methods with interface parameters work" $ do
ctrA <- atomically $ newTestCtr 2
ctrB <- atomically $ newTestCtr 0
ctrC <- atomically $ newTestCtr 30
runVatPair
(\sup -> export @CounterAcceptor sup TestCtrAcceptor)
( \sup acceptor -> do
for_ [ctrA, ctrB, ctrC] $ \ctrSrv -> do
ctr <- atomically $ export @CallSequence sup ctrSrv
acceptor
& callP #accept CounterAcceptor'accept'params {counter = ctr}
>>= waitPipeline @CounterAcceptor'accept'results
r <-
traverse
(\(TestCtrServer var) -> liftIO $ readTVarIO var)
[ctrA, ctrB, ctrC]
liftIO $ r `shouldBe` [7, 5, 35]
)
describe "revokers" $ revokerTests
-- | Tests for revokers.
revokerTests :: Spec
revokerTests = do
it "should allow methods through before revocation" $ do
withSupervisor $ \sup -> do
factory <- export @CounterFactory sup (TestCtrFactory sup)
(mfactory, _revoke) <- makeRevocable sup factory
CallSequence'getNumber'results {n} <-
mfactory
& callP #newCounter def {start = 0}
<&> pipe #counter
>>= callP #getNumber def
>>= waitPipeline
>>= evalLimitT defaultLimit . parse
n `shouldBe` 1
it "should block methods after revocation" $ do
withSupervisor $ \sup -> do
factory <- export @CounterFactory sup (TestCtrFactory sup)
(mfactory, revoke) <- makeRevocable sup factory
atomically revoke
result <-
try $
mfactory
& callP #newCounter def {start = 0}
>>= waitPipeline
case result of
Left (_ :: SomeException) -> pure ()
Right _ -> error "Expected an error after revocation."
it "should block methods on returned objects after revocation" $ do
withSupervisor $ \sup -> do
factory <- export @CounterFactory sup (TestCtrFactory sup)
(mfactory, revoke) <- makeRevocable sup factory
CounterFactory'newCounter'results {counter = mctr} <-
mfactory
& callP #newCounter def {start = 0}
>>= waitPipeline
>>= evalLimitT defaultLimit . parse
atomically revoke
result <-
try $
mctr
& callP #getNumber def
>>= waitPipeline
case result of
Left (_ :: SomeException) -> pure ()
Right _ -> error "Expected an error after revocation."
data TestCtrAcceptor = TestCtrAcceptor
instance SomeServer TestCtrAcceptor
instance CounterAcceptor'server_ TestCtrAcceptor where
counterAcceptor'accept _ =
handleParsed $ \CounterAcceptor'accept'params {counter} -> do
[start] <- map n <$> bumpN counter 1
r <- bumpN counter 4
liftIO $
r
`shouldBe` [ def {n = start + 1},
def {n = start + 2},
def {n = start + 3},
def {n = start + 4}
]
pure def
-------------------------------------------------------------------------------
-- Implementations of various interfaces for testing purposes.
-------------------------------------------------------------------------------
newtype TestCtrFactory = TestCtrFactory {sup :: Supervisor}
instance SomeServer TestCtrFactory
instance CounterFactory'server_ TestCtrFactory where
counterFactory'newCounter TestCtrFactory {sup} =
handleParsed $ \CounterFactory'newCounter'params {start} -> do
ctr <- atomically $ newTestCtr start >>= export @CallSequence sup
pure CounterFactory'newCounter'results {counter = ctr}
newTestCtr :: Word32 -> STM TestCtrServer
newTestCtr n = TestCtrServer <$> newTVar n
newtype TestCtrServer = TestCtrServer (TVar Word32)
instance SomeServer TestCtrServer
instance CallSequence'server_ TestCtrServer where
callSequence'getNumber (TestCtrServer tvar) =
handleParsed $ \_ -> do
ret <- liftIO $ atomically $ do
modifyTVar' tvar (+ 1)
readTVar tvar
pure def {n = ret}
-- a 'CallSequence' which always throws an exception.
data ExnCtrServer = ExnCtrServer
instance SomeServer ExnCtrServer
instance CallSequence'server_ ExnCtrServer where
callSequence'getNumber _ = handleParsed $ \_ ->
throwM
def
{ type_ = Exception'Type'failed,
reason = "Something went sideways."
}
-- a 'CallSequence' which doesn't implement its methods.
data NoImplServer = NoImplServer
instance SomeServer NoImplServer
instance CallSequence'server_ NoImplServer -- TODO: can we silence the warning somehow?
-- Server that throws some non-rpc exception.
data NonRpcExnServer = NonRpcExnServer
instance SomeServer NonRpcExnServer
instance CallSequence'server_ NonRpcExnServer where
callSequence'getNumber _ = handleParsed $ \_ -> error "OOPS"
-------------------------------------------------------------------------------
-- Tests for unusual patterns of messages .
--
-- Some of these will never come up when talking to a correct implementation of
-- capnproto, and others just won't come up when talking to the Haskell
-- implementation. Accordingly, these tests start a vat in one thread and
-- directly manipulate the transport in the other.
-------------------------------------------------------------------------------
unusualTests :: Spec
unusualTests = describe "Tests for unusual message patterns" $ do
it "Should raise ReceivedAbort in response to an abort message." $ do
-- Send an abort message to the remote vat, and verify that
-- the vat actually aborts.
let exn =
def
{ type_ = Exception'Type'failed,
reason = "Testing abort"
}
withTransportPair $ \(vatTrans, probeTrans) -> do
ret <- try
$ concurrently_
(handleConn (vatTrans defaultLimit) def {debugMode = True})
$ do
msg <- createPure maxBound $ parsedToMsg $ Message $ Message'abort exn
sendMsg (probeTrans defaultLimit) msg
ret `shouldBe` Left (ReceivedAbort exn)
triggerAbort (Message'unimplemented $ Message $ Message'abort def) $
"Your vat sent an 'unimplemented' message for an abort message "
<> "that its remote peer never sent. This is likely a bug in your "
<> "capnproto library."
triggerAbort
( Message'call
( def
{ target = MessageTarget $ MessageTarget'importedCap 443
} ::
Parsed Call
)
)
"No such export: 443"
triggerAbort
( Message'call
( def
{ target =
MessageTarget $
MessageTarget'promisedAnswer (def {questionId = 300} :: Parsed PromisedAnswer)
} ::
Parsed Call
)
)
"No such answer: 300"
triggerAbort
(Message'return def {answerId = 234})
"No such question: 234"
it "Should respond with an abort if sent junk data" $ do
let wantAbortExn =
def
{ reason = "Unhandled exception: TraversalLimitError",
type_ = Exception'Type'failed
}
withTransportPair $ \(vatTrans, probeTrans) ->
concurrently_
( do
Left (e :: RpcError) <-
try $
handleConn (vatTrans defaultLimit) def {debugMode = True}
e `shouldBe` SentAbort wantAbortExn
)
( do
let bb =
mconcat
[ BB.word32LE 0, -- 1 segment - 1 = 0
BB.word32LE 2, -- 2 words in first segment
-- a pair of structs that point to each other:
BB.word64LE (P.serializePtr (Just (P.StructPtr 0 0 1))),
BB.word64LE (P.serializePtr (Just (P.StructPtr (-1) 0 1)))
]
lbs = BB.toLazyByteString bb
msg <- lbsToMsg lbs
sendMsg (probeTrans defaultLimit) msg
msg' <- recvMsg (probeTrans defaultLimit)
resp <- evalLimitT maxBound $ msgToParsed msg'
resp `shouldBe` Message (Message'abort wantAbortExn)
)
it "Should respond with an abort if erroneously sent return = resultsSentElsewhere" $
withTransportPair $ \(vatTrans, probeTrans) ->
let wantExn =
eFailed $
"Received Return.resultsSentElswhere for a call "
<> "with sendResultsTo = caller."
in concurrently_
( do
Left (e :: RpcError) <-
try $
handleConn
(vatTrans defaultLimit)
def
{ debugMode = True,
withBootstrap = Just $ \_sup client ->
let ctr :: Client CallSequence = fromClient client
in void $ (ctr & callR #getNumber def) >>= waitPipeline
}
e `shouldBe` SentAbort wantExn
)
( do
let send msg =
evalLimitT maxBound (parsedToMsg msg >>= freeze)
>>= sendMsg (probeTrans defaultLimit)
recv =
recvMsg (probeTrans defaultLimit)
>>= evalLimitT maxBound . msgToParsed
Message'bootstrap Bootstrap {} <- recv
Message'call Call {questionId} <- recv
send $
Message'return
def
{ answerId = questionId,
union' = Return'resultsSentElsewhere
}
msg <- recv
msg `shouldBe` Message'abort wantExn
)
it "Should reply with unimplemented when sent a join (level 4 only)." $
withTransportPair $ \(vatTrans, probeTrans) ->
race_
(handleConn (vatTrans defaultLimit) def {debugMode = True})
$ do
msg <- createPure maxBound $ parsedToMsg $ Message'join def
sendMsg (probeTrans defaultLimit) msg
msg' <-
recvMsg (probeTrans defaultLimit)
>>= evalLimitT maxBound . msgToParsed
msg' `shouldBe` Message (Message'unimplemented (Message (Message'join def)))
-- | Verify that the given message triggers an abort with the specified 'reason'
-- field.
triggerAbort :: Parsed (Which Message) -> T.Text -> Spec
triggerAbort msg reason =
it ("Should abort when sent the message " ++ show msg ++ " on startup") $ do
let wantAbortExn =
def
{ reason = reason,
type_ = Exception'Type'failed
}
withTransportPair $ \(vatTrans, probeTrans) ->
concurrently_
( do
ret <- try $ handleConn (vatTrans defaultLimit) def {debugMode = True}
ret `shouldBe` Left (SentAbort wantAbortExn)
)
( do
rawMsg <- createPure maxBound $ parsedToMsg msg
sendMsg (probeTrans defaultLimit) rawMsg
-- 4 second timeout. The remote vat's timeout before killing the
-- connection is one second, so if this happens we're never going
-- to receive the message. In theory this is possible, but if it
-- happens something is very wrong.
r <- timeout 4000000 $ recvMsg (probeTrans defaultLimit)
case r of
Nothing ->
error "Test timed out waiting on abort message."
Just rawResp -> do
resp <- evalLimitT maxBound $ msgToParsed rawResp
resp `shouldBe` Message (Message'abort wantAbortExn)
)
-------------------------------------------------------------------------------
-- Utilties used by the tests.
-------------------------------------------------------------------------------
withSocketPair :: ((Socket.Socket, Socket.Socket) -> IO a) -> IO a
withSocketPair =
bracket
(Socket.socketPair Socket.AF_UNIX Socket.Stream 0)
(\(x, y) -> Socket.close x >> Socket.close y)
withTransportPair ::
( ( WordCount -> Transport,
WordCount -> Transport
) ->
IO a
) ->
IO a
withTransportPair f =
withSocketPair $ \(x, y) -> f (socketTransport x, socketTransport y)
-- | @'runVatPair' server client@ runs a pair of vats connected to one another,
-- using 'server' as the 'offerBootstrap' field in the one vat's config, and
-- 'client' as the 'withBootstrap' field in the other's.
runVatPair :: IsClient c => (Supervisor -> STM c) -> (Supervisor -> c -> IO ()) -> IO ()
runVatPair getBootstrap withBootstrap = withTransportPair $ \(clientTrans, serverTrans) -> do
let runClient =
handleConn
(clientTrans defaultLimit)
def
{ debugMode = True,
withBootstrap = Just $ \sup -> withBootstrap sup . fromClient
}
runServer =
handleConn
(serverTrans defaultLimit)
def
{ debugMode = True,
getBootstrap = fmap (Just . toClient) . getBootstrap
}
race_ runServer runClient
expectException ::
( IsCap c,
TypeParam a,
Parse a pa,
Show pa,
R.ReprFor a ~ 'R.Ptr pr
) =>
(Client c -> IO (Pipeline a)) ->
Parsed Exception ->
Client c ->
IO ()
expectException callFn wantExn cap = do
ret <- try $ callFn cap >>= waitPipeline
case ret of
Left (e :: Parsed Exception) ->
liftIO $ e `shouldBe` wantExn
Right val -> do
parsed <- evalLimitT defaultLimit (parse val)
error $ "Should have received exn, but got " ++ show parsed