packages feed

ide-backend-0.9.0: test/rpc-server.hs

{-# LANGUAGE TemplateHaskell, ScopedTypeVariables, ExistentialQuantification, DeriveDataTypeable #-}
-- | Test suite for the RPC infrastructure
module Main where

import Control.Applicative ((<$>), (<*>))
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar
import Control.Monad (forM_, forever, replicateM, replicateM_)
import Data.Binary (Binary)
import Data.Function (on)
import Data.List (isInfixOf)
import Data.Typeable (Typeable)
import System.Environment (getArgs)
import System.Environment.Executable (getExecutablePath)
import System.FilePath ((</>))
import System.IO (hClose)
import System.IO.Temp (withTempDirectory, openTempFile)
import System.Posix.Files (createNamedPipe)
import System.Posix.Signals (sigKILL)
import qualified Control.Concurrent.Async as Async
import qualified Control.Exception        as Ex
import qualified Data.Binary              as Binary
import qualified System.Directory         as Dir

import Test.Framework (Test, defaultMain, testGroup)
import Test.Framework.Providers.HUnit (testCase)
import Test.HUnit (Assertion, assertEqual)

import IdeSession.RPC.Client
import IdeSession.RPC.Server
import TestTools

--------------------------------------------------------------------------------
-- RPC-specific auxiliary                                                     --
--------------------------------------------------------------------------------

-- | Call the current executable and pass the right arguments
forkTestServer :: String -> IO RpcServer
forkTestServer test = do
  prog <- getExecutablePath
  forkRpcServer prog ["--server", test] Nothing Nothing

-- | Do an RPC call and verify the result
assertRpcEqual :: (Typeable req, Typeable resp, Binary req, Binary resp, Show req, Show resp, Eq resp)
               => RpcServer -- ^ RPC server
               -> req       -- ^ Request
               -> resp      -- ^ Expected response
               -> Assertion
assertRpcEqual server req resp =
  assertRpcEquals server req [resp]

-- | Like 'assertRpcEqual' but verify a number of responses, before throwing
-- the specified exception (if any)
assertRpcEquals :: (Typeable req, Typeable resp, Binary req, Binary resp, Show req, Show resp, Eq resp)
                => RpcServer -- ^ RPC server
                -> req       -- ^ Request
                -> [resp]    -- ^ Expected responses
                -> Assertion
assertRpcEquals server req resps =
  assertRpc server (Put req $ foldr Get Done resps)

data Conversation =
    forall req.  (Show req,  Typeable req,  Binary req) => Put req  Conversation
  | forall resp. (Show resp, Typeable resp, Binary resp, Eq resp) => Get resp Conversation
  | Done

assertRpc :: RpcServer -> Conversation -> Assertion
assertRpc server conversation = do
  rpcConversation server $ \RpcConversation{..} ->
    let checkConversation Done =
          return ()
        checkConversation (Put req conv) = do
          put req
          checkConversation conv
        checkConversation (Get resp conv) = do
          assertEqual "" resp =<< get
          checkConversation conv
    in checkConversation conversation

isServerKilledException :: ExternalException -> Bool
isServerKilledException =
  ((==) `on` externalStdErr) (serverKilledException undefined)

isServerIOException :: String -> ExternalException -> Bool
isServerIOException ex ExternalException{externalStdErr} =
  ex `isInfixOf` externalStdErr

--------------------------------------------------------------------------------
-- Feature tests                                                              --
--------------------------------------------------------------------------------

-- | Simple echo server
testEcho :: RpcServer -> Assertion
testEcho server = assertRpcEqual server "ping" "ping"

testEchoServer :: FilePath -> RpcConversation -> IO ()
testEchoServer _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO String
  put req

-- | Test stateful server
testState :: RpcServer -> Assertion
testState server = forM_ ([0 .. 9] :: [Int]) $ assertRpcEqual server ()

testStateServer :: MVar Int -> FilePath -> RpcConversation -> IO ()
testStateServer st _errorLog RpcConversation{..} = forever $ do
  () <- get
  modifyMVar_ st $ \i -> do
    put i
    return (i + 1)

-- | Test with request and response custom data types
data CountRequest  = Increment | GetCount
  deriving (Show, Typeable)
data CountResponse = DoneCounting | Count Int
  deriving (Eq, Show, Typeable)

instance Binary CountRequest where
  put Increment = Binary.putWord8 0
  put GetCount  = Binary.putWord8 1

  get = do
    header <- Binary.getWord8
    case header of
      0 -> return Increment
      1 -> return GetCount
      _ -> fail "CountRequest.get: invalid header"

instance Binary CountResponse where
  put DoneCounting = Binary.putWord8 0
  put (Count i)    = Binary.putWord8 1 >> Binary.put i

  get = do
    header <- Binary.getWord8
    case header of
      0 -> return DoneCounting
      1 -> Count <$> Binary.get
      _ -> fail "CountResponse.get: invalid header"

testCustom :: RpcServer -> Assertion
testCustom server = do
  assertRpcEqual server GetCount (Count 0)
  assertRpcEqual server Increment DoneCounting
  assertRpcEqual server GetCount (Count 1)

testCustomServer :: MVar Int -> FilePath -> RpcConversation -> IO ()
testCustomServer st _errorLog RpcConversation{..} = forever $ do
  req <- get
  case req of
    Increment -> modifyMVar_ st $ \i -> do
      put DoneCounting
      return (i + 1)
    GetCount -> modifyMVar_ st $ \i -> do
      put (Count i)
      return i

-- | Test progress messages
testProgress :: RpcServer -> Assertion
testProgress server =
  forM_ ([0 .. 9] :: [Int]) $ \i -> assertRpcEquals server i [i, i - 1 .. 0]

testProgressServer :: FilePath -> RpcConversation -> IO ()
testProgressServer _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO Int
  forM_ [req, req - 1 .. 1] $ put
  put (0 :: Int)

-- | Test shutdown
testShutdown :: RpcServer -> Assertion
testShutdown server = do
  assertRpcEqual server "ping" "ping"
  shutdown server
  assertRaises "" (== (userError "Manual shutdown"))
    (rpc server "ping" :: IO String)

-- | Test that stdout is available as usual on the server
testStdout :: RpcServer -> Assertion
testStdout server = do
  assertRpcEqual server "ping" "ping"
  shutdown server
  assertRaises "" (== (userError "Manual shutdown"))
    (rpc server "ping" :: IO String)

testStdoutServer :: FilePath -> RpcConversation -> IO ()
testStdoutServer _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO String
  putStrLn "   vvvv    testStdout intentionally printing to stdout"
  put req

-- | Test that we can do concurrent get and put
testConcurrentGetPut :: RpcServer -> Assertion
testConcurrentGetPut server =
  rpcConversation server $ \RpcConversation{..} -> do
    forkIO $ threadDelay 1000000 >> put (1 :: Int)
    Right 1 <- Ex.try get :: IO (Either Ex.SomeException Int)
    return ()

testConcurrentGetPutServer :: FilePath -> RpcConversation -> IO ()
testConcurrentGetPutServer _errorLog RpcConversation{..} = forever $ do
  i <- get
  put (i :: Int)

--------------------------------------------------------------------------------
-- Test generalized conversations                                             --
--------------------------------------------------------------------------------

data StartGame  = StartGame Int Int | NoMoreGames
  deriving (Show, Typeable)
data Guess = Guess Int | GiveUp | Yay
  deriving (Show, Typeable, Eq)
data GuessReply = GuessCorrect | GuessIncorrect
  deriving (Show, Typeable)

instance Binary StartGame where
  put (StartGame i j) = do Binary.putWord8 0
                           Binary.put i
                           Binary.put j
  put NoMoreGames     = Binary.putWord8 1

  get = do
    header <- Binary.getWord8
    case header of
      0 -> StartGame <$> Binary.get <*> Binary.get
      1 -> return NoMoreGames
      _ -> fail "Binary.get: Invalid header"

instance Binary Guess where
  put (Guess i) = Binary.putWord8 0 >> Binary.put i
  put GiveUp    = Binary.putWord8 1
  put Yay       = Binary.putWord8 2

  get = do
    header <- Binary.getWord8
    case header of
      0 -> Guess <$> Binary.get
      1 -> return GiveUp
      2 -> return Yay
      _ -> fail "Guess.get: invalid header"

instance Binary GuessReply where
  put GuessCorrect   = Binary.putWord8 0
  put GuessIncorrect = Binary.putWord8 1

  get = do
    header <- Binary.getWord8
    case header of
      0 -> return GuessCorrect
      1 -> return GuessIncorrect
      _ -> fail "GuessReply.get: invalid header"

testConversation :: RpcServer -> Assertion
testConversation server = do
  assertRpc server
    $ Put (StartGame 1 10)
    $ Get (Guess 1)
    $ Put GuessIncorrect
    $ Get (Guess 2)
    $ Put GuessIncorrect
    $ Get (Guess 3)
    $ Put GuessCorrect
    $ Get Yay
    $ Done
  assertRpc server
    $ Put (StartGame 1 2)
    $ Get (Guess 1)
    $ Put GuessIncorrect
    $ Get (Guess 2)
    $ Put GuessIncorrect
    $ Get GiveUp
    $ Done
  {- DISABLED. Whether or not we get this exception, or just sit here and wait,
     depends on circumstances. Perhaps it would be nicer if we did tag messages
     with some type information so that can catch these errors properly.
  assertRaises "" typeError $
    assertRpcEqual server GuessCorrect GiveUp
  where
    typeError :: ExternalException -> Bool
    typeError ExternalException{externalStdErr} =
      "not enough bytes" `isInfixOf` externalStdErr
  -}
  assertRpcEqual server NoMoreGames ()

testConversationServer :: FilePath -> RpcConversation -> IO ()
testConversationServer _errorLog RpcConversation{..} = outerLoop
  where
    outerLoop = do
      req <- labelExceptions "testConversationServer outerLoop: " $ get
      case req of
        StartGame n m -> innerLoop n m >> outerLoop
        NoMoreGames   -> put () >> return ()

    innerLoop n m
      | n > m     = put GiveUp
      | otherwise = do put (Guess n)
                       answer <- labelExceptions "testConcurrentServer innerLoop: " $ get
                       case answer of
                         GuessCorrect   -> put Yay
                         GuessIncorrect -> innerLoop (n + 1) m

--------------------------------------------------------------------------------
-- Error handling tests                                                       --
--------------------------------------------------------------------------------

-- | Test crashing server
testCrash :: RpcServer -> Assertion
testCrash server =
  assertRaises "" (isServerIOException crash) $
    assertRpcEqual server () ()

testCrashServer :: FilePath -> RpcConversation -> IO ()
testCrashServer _errorLog RpcConversation{..} = do
  () <- get
  Ex.throwIO (userError crash)

crash :: String
crash = "Intentional crash"

-- | Test server which gets killed during a request
testKill :: RpcServer -> Assertion
testKill server = do
  assertRpcEqual server "ping" "ping"   -- First request goes through
  assertRaises "" isServerKilledException $
    assertRpcEqual server "ping" "ping" -- Second does not

testKillServer :: MVar Bool -> FilePath -> RpcConversation -> IO ()
testKillServer firstRequest _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO String
  modifyMVar_ firstRequest $ \isFirst -> do
    if isFirst
      then put req
      else throwSignal sigKILL
    return False

-- | Test server which gets killed between requests
testKillAsync :: RpcServer -> Assertion
testKillAsync server = do
  assertRpcEqual server "ping" "ping"
  threadDelay 500000 -- Wait for server to exit
  assertRaises "" isServerKilledException $
    assertRpcEqual server "ping" "ping"

testKillAsyncServer :: FilePath -> RpcConversation -> IO ()
testKillAsyncServer _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO String
  -- Fork a thread which causes the server to crash 0.5 seconds after the request
  forkIO $ threadDelay 250000 >> throwSignal sigKILL
  put req

-- | Test crash during decoding
data TypeWithFaultyDecoder = TypeWithFaultyDecoder
  deriving (Show, Typeable)

instance Binary TypeWithFaultyDecoder where
  put _ = Binary.putWord8 0 -- >> return ()
  get = fail "Faulty decoder"

testFaultyDecoder :: RpcServer -> Assertion
testFaultyDecoder server =
  assertRaises "" (isServerIOException "Faulty decoder") $
    assertRpcEqual server TypeWithFaultyDecoder ()

testFaultyDecoderServer :: FilePath -> RpcConversation -> IO ()
testFaultyDecoderServer _errorLog RpcConversation{..} = forever $ do
  TypeWithFaultyDecoder <- get
  put ()

-- | Test crash during encoding
data TypeWithFaultyEncoder = TypeWithFaultyEncoder
  deriving (Show, Eq, Typeable)

instance Binary TypeWithFaultyEncoder where
  put _ = fail "Faulty encoder"
  get = Binary.getWord8 >> return TypeWithFaultyEncoder

testFaultyEncoder :: RpcServer -> Assertion
testFaultyEncoder server =
  assertRaises "" ((== "Faulty encoder") . externalStdErr) $
    assertRpcEqual server () TypeWithFaultyEncoder

testFaultyEncoderServer :: FilePath -> RpcConversation -> IO ()
testFaultyEncoderServer _errorLog RpcConversation{..} = forever $ do
  () <- get
  put TypeWithFaultyEncoder

--------------------------------------------------------------------------------
-- Test errors during RPC calls with multiple responses                       --
--------------------------------------------------------------------------------

-- | Test server which crashes after sending some intermediate responses
testCrashMulti :: RpcServer -> Assertion
testCrashMulti server =
  assertRaises "" (isServerIOException crash) $
    assertRpcEquals server (3 :: Int) ([3, 2, 1, 0] :: [Int])

testCrashMultiServer :: FilePath -> RpcConversation -> IO ()
testCrashMultiServer _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO Int
  forM_ [req, req - 1 .. 1] $ put
  Ex.throwIO (userError crash)

-- | Like 'CrashMulti', but killed rather than an exception
testKillMulti :: RpcServer -> Assertion
testKillMulti server =
  assertRaises "" isServerKilledException $
    assertRpcEquals server (3 :: Int) ([3, 2, 1, 0] :: [Int])

testKillMultiServer :: FilePath -> RpcConversation -> IO ()
testKillMultiServer _errorLog RpcConversation{..} = forever $ do
  req <- get :: IO Int
  forM_ [req, req - 1 .. 1] $ put
  throwSignal sigKILL

-- | Like 'KillMulti', but now the server gets killed *between* messages
testKillAsyncMulti :: RpcServer -> Assertion
testKillAsyncMulti server =
  assertRaises "" isServerKilledException $
    assertRpcEquals server (3 :: Int) ([3, 2, 1, 0] :: [Int])

testKillAsyncMultiServer :: FilePath -> RpcConversation -> IO ()
testKillAsyncMultiServer _errorLog RpcConversation{..} = forever $ do
  req <- get
  forkIO $ threadDelay (250000 + (req - 1) * 50000) >> throwSignal sigKILL
  forM_ [req, req - 1 .. 1] $ \i -> threadDelay 50000 >> put i

--------------------------------------------------------------------------------
-- Tests for errors in client code                                            --
--------------------------------------------------------------------------------

-- | Test letting the Progress object escape from the scope
testIllscoped :: RpcServer -> Assertion
testIllscoped server = do
  conversation <- rpcConversation server $ return
  assertRaises "" (== illscopedConversationException) (get conversation :: IO String)
  assertRaises "" (== illscopedConversationException) (put conversation "hi")

-- | Test what happens if the client specifies the wrong request type
--
-- (The actual type is the type of the echo server: RpcServer String String)
testInvalidReqType :: RpcServer -> Assertion
testInvalidReqType server =
    assertRaises "" (isServerIOException parseEx) $
      assertRpcEqual server () "ping"
  where
    parseEx = "not enough bytes"

{- DISABLED: We cannot detect this reliably anymore, since we don't
  encode type information anymore since the move to Binary
-- | Test what happens if the client specifies the wrong response type
--
-- Note that since the decoding error now happens *locally*, the exception
-- is a regular userError, rather than an ExternalException
--
-- (The actual type is the type of the echo server: RpcServer String String)
testInvalidRespType :: RpcServer -> Assertion
testInvalidRespType server =
    assertRaises "" (== userError parseEx) $
      assertRpcEqual server "ping" ()
  where
    -- TODO: do we want to insist on this particular parse error?
    parseEx = "when expecting a (), encountered String instead"
-}

--------------------------------------------------------------------------------
-- Concurrent conversations                                                   --
--                                                                            --
-- It is important that all servers involved with the concurrent conversations--
-- have a proper shutdown sequence or else we will get runtime exceptions when--
-- we close the pipes.                                                        --
--------------------------------------------------------------------------------

data ConcurrentServerRequest =
    ConcurrentServerSanityCheck String
  | ConcurrentServerSpawn
  | ConcurrentServerTerminate
  deriving (Typeable, Show)

instance Binary ConcurrentServerRequest where
  put (ConcurrentServerSanityCheck s) = Binary.putWord8 0 >> Binary.put s
  put ConcurrentServerSpawn           = Binary.putWord8 1
  put ConcurrentServerTerminate       = Binary.putWord8 2

  get = do
    header <- Binary.getWord8
    case header of
      0 -> ConcurrentServerSanityCheck <$> Binary.get
      1 -> return ConcurrentServerSpawn
      2 -> return ConcurrentServerTerminate
      _ -> fail "ConcurrentServerRequest.get: invalid header"

testConcurrentServer :: FilePath -> RpcConversation -> IO ()
testConcurrentServer _errorLog RpcConversation{..} = go
  where
    go = do
      req <- labelExceptions "testConcurrentServer: " $ get
      case req of
        ConcurrentServerSanityCheck str -> do
          put str
          go
        ConcurrentServerSpawn -> do
          pipes <- newEmptyMVar :: IO (MVar (String, String, String))
          forkIO $ do
            withTempDirectory "." "rpc" $ \tempDir -> do
              let stdin  = tempDir </> "stdin"
                  stdout = tempDir </> "stdout"
                  stderr = tempDir </> "stderr"

              createNamedPipe stdin  0o600
              createNamedPipe stdout 0o600

              tmpDir <- Dir.getTemporaryDirectory
              (errorLogPath, errorLogHandle) <- openTempFile tmpDir "rpc.log"
              hClose errorLogHandle

              -- Once we have created the pipes we can tell the client
              putMVar pipes (stdin, stdout, errorLogPath)
              concurrentConversation stdin stdout stderr testConversationServer

          (stdin, stdout, errorLogPath) <- readMVar pipes
          put (stdin, stdout, errorLogPath)
          go
        ConcurrentServerTerminate -> do
          put ()
          return ()

testConcurrent :: RpcServer -> Assertion
testConcurrent server = do
  -- test sequentially: echo, spawn conversation, run that conversation, repeat
  replicateM_ 3 $ do
    -- test we can echo
    assertRpcEqual server (ConcurrentServerSanityCheck "ping") "ping"

    (stdin, stdout, stderr) <- rpc server ConcurrentServerSpawn
    connectToRpcServer stdin stdout stderr $ testConversation

  -- test concurrent execution: spawn a bunch of servers, have conversations
  -- with all of them concurrently, while still communicating on the original
  -- conversation, too (note that the calls to 'rpc' to start the concurrent
  -- conversations will be sequentialized)
  clientConvs <- replicateM 10 $ Async.async $ do
    (stdin, stdout, stderr) <- rpc server ConcurrentServerSpawn
    connectToRpcServer stdin stdout stderr $ testConversation

  -- Echo on the original conversation while we have the other conversations
  replicateM_ 100 $
    assertRpcEqual server (ConcurrentServerSanityCheck "ping") "ping"

  -- Wait for all client conversations to finish
  forM_ clientConvs Async.wait

  -- Shutdown the main server
  assertRpcEqual server ConcurrentServerTerminate ()

--------------------------------------------------------------------------------
-- Driver                                                                     --
--------------------------------------------------------------------------------

tests :: [Test]
tests = [
    testGroup "Features" [
        testRPC "echo"             testEcho
      , testRPC "state"            testState
      , testRPC "custom"           testCustom
      , testRPC "progress"         testProgress
      , testRPC "shutdown"         testShutdown
      , testRPC "stdout"           testStdout
      , testRPC "conversation"     testConversation
      , testRPC "concurrentGetPut" testConcurrentGetPut
      ]
  , testGroup "Error handling" [
        testRPC "crash"            testCrash
      , testRPC "kill"             testKill
      , testRPC "killAsync"        testKillAsync
      , testRPC "faultyDecoder"    testFaultyDecoder
      , testRPC "faultyEncoder"    testFaultyEncoder
      ]
   , testGroup "Error handling during RPC calls with multiple responses" [
         testRPC "crashMulti"       testCrashMulti
       , testRPC "killMulti"        testKillMulti
       , testRPC "killAsyncMulti"   testKillAsyncMulti
       ]
   , testGroup "Client code errors" [
         testRPC "illscoped"        testIllscoped
       , testRPC "invalidReqType"   testInvalidReqType
--       , testRPC "invalidRespType"  testInvalidRespType
       ]
   , testGroup "Concurrent conversations" [
         testRPC "concurrent" testConcurrent
       ]
  ]
  where
    testRPC :: String -> (RpcServer -> Assertion) -> Test
    testRPC name testWith = testCase name $ do
      server <- forkTestServer name
      testWith server
      shutdown server

main :: IO ()
main = do
  args <- getArgs
  case args of
    "--server" : test : args' -> case test of
      "echo"             -> rpcServer testEchoServer args'
      "state"            -> do st <- newMVar 0
                               rpcServer (testStateServer st) args'
      "custom"           -> do st <- newMVar 0
                               rpcServer (testCustomServer st) args'
      "progress"         -> rpcServer testProgressServer args'
      "shutdown"         -> rpcServer testEchoServer args'
      "stdout"           -> rpcServer testStdoutServer args'
      "conversation"     -> rpcServer testConversationServer args'
      "concurrentGetPut" -> rpcServer testConcurrentGetPutServer args'
      "crash"            -> rpcServer testCrashServer args'
      "kill"             -> do firstRequest <- newMVar True
                               rpcServer (testKillServer firstRequest) args'
      "killAsync"        -> rpcServer testKillAsyncServer args'
      "faultyDecoder"    -> rpcServer testFaultyDecoderServer args'
      "faultyEncoder"    -> rpcServer testFaultyEncoderServer args'
      "illscoped"        -> rpcServer testEchoServer args'
      "underconsumption" -> rpcServer testEchoServer args'
      "overconsumption"  -> rpcServer testEchoServer args'
      "crashMulti"       -> rpcServer testCrashMultiServer args'
      "killMulti"        -> rpcServer testKillMultiServer args'
      "killAsyncMulti"   -> rpcServer testKillAsyncMultiServer args'
      "invalidReqType"   -> rpcServer testEchoServer args'
      "invalidRespType"  -> rpcServer testEchoServer args'
      "concurrent"       -> rpcServer testConcurrentServer args'
      _ -> error $ "Invalid server " ++ show test
    _ -> defaultMain tests

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

labelExceptions :: String -> IO a -> IO a
labelExceptions label = Ex.handle aux
  where
    aux :: Ex.SomeException -> IO a
    aux e = Ex.throwIO (userError (label ++ show e))