packages feed

jupyter-0.9.0: tests/Jupyter/Test/MessageExchange.hs

{-|
Module      : Jupyter.Test.MessageExchange
Description : Testing infrastructure for back-and-forth communication between clients and kernels
Copyright   : (c) Andrew Gibiansky, 2016
License     : MIT
Maintainer  : andrew.gibiansky@gmail.com
Stability   : stable
Portability : POSIX

This module serves as one mechanism of testing @jupyter@'s Client functionality, as well as kernels 
included with this project.

This module allows defining a list of 'MessageExchange' values, which indicate a complete message exchange
that should happen between a client and a kernel. The 'testMessageExchange' function then takes the list of
exchanges, runs them using the 'Client' interface, and verifies that all replies (and generated kernel
requests and outputs) match the expected ones.
-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE OverloadedStrings #-}
module Jupyter.Test.MessageExchange (
    MessageExchange(..),
    startKernel,
    fakeUUID,
    testMessageExchange,
    runKernelAndClient,
    ) where

-- Imports from 'base'
import           Control.Concurrent (newMVar, swapMVar, newEmptyMVar, MVar, putMVar, takeMVar,
                                     readMVar, modifyMVar_, threadDelay, tryTakeMVar)
import           Control.Monad (forM_, unless, void)
import           Data.Maybe (listToMaybe)
import           System.Environment (setEnv)
import           System.Timeout (timeout)

-- Imports from 'transformers'
import           Control.Monad.IO.Class (liftIO)

-- Imports from 'exceptions'
import           Control.Monad.Catch (finally)

-- Imports from 'unordered-containers'
import qualified Data.HashMap.Strict as HashMap

-- Imports from 'tasty'
import           Test.Tasty (TestTree)

-- Imports from 'tasty-hunit'
import           Test.Tasty.HUnit (testCaseSteps, (@=?), assertFailure)

-- Imports from 'aeson'
import           Data.Aeson.Types (Value(..))

-- Imports from 'process'
import           System.Process (spawnProcess, terminateProcess, ProcessHandle, getProcessExitCode)

-- Imports from 'jupyter'
import           Jupyter.Client
import           Jupyter.Kernel
import           Jupyter.Messages
import qualified Jupyter.UUID as UUID

import           Jupyter.Test.Utils (inTempDir)

-- | A data type representing an exchange of messages between a client and a kernel.
--
-- Given a list of 'MessageExchange's, this module can run the communication and check that all
-- requests, outputs, and replies match what was specified.
--
-- This currently does not support sending 'Comm' messages as part of the exchange, but does support
-- receiving them.
data MessageExchange =
       MessageExchange
         { exchangeName :: String
         -- ^ Name for the message exchange (to show in errors and test outputs)
         , exchangeRequest :: ClientRequest
         -- ^ The 'ClientRequest' that initiates the exchange
         , exchangeReply :: KernelReply
         -- ^ The 'KernelReply' that is returned
         , exchangeKernelRequests :: [(KernelRequest, ClientReply)]
         -- ^ The 'KernelRequest's that are sent to the client, with a reply for each one
         , exchangeComms :: [Comm]
         -- ^ The 'Comm' messages that are sent to the client
         , exchangeKernelOutputs :: [KernelOutput]
         -- ^ The 'KernelOutput' messages that are sent to the client
         }
  deriving (Eq, Show)

-- | Start an external-process kernel.
--
-- First, write the provided profile to a randomly named JSON file. Then, pass the path to this
-- connection file to the provided function, which should return the command that should be used to
-- start the kernel. Return the 'ProcessHandle' for the kernel process.
--
-- For example, given a profile @kernelProfile@, the IPython kernel would be started as
--
-- >     startKernel (\connFile -> ["python", "-m", "ipykernel", "-f", connFile]) kernelProfile
--
-- It is recommended that this be run in a temporary directory that is deleted afterward, so that
-- this does not litter the user's path with JSON connection files.
startKernel :: (FilePath -> [String]) -- ^ Function to generate kernel command given JSON connection
                                      -- file
            -> KernelProfile -- ^ Kernel profile to write to the connection file
            -> IO ProcessHandle -- ^ Handle to the spawned kernel process
startKernel mkCmd profile = do
  -- Write a randomly-named profile JSON file. These are randomly named to avoid the possibility of
  -- accidentally passing an old connection file to a newly spawned kernel.
  uuid <- UUID.uuidToString <$> UUID.random
  let filename = "profile-" ++ uuid ++ ".json"
  writeProfile profile filename

  -- Set JPY_PARENT_PID to shut up the kernel about its Ctrl-C behaviour (this is mostly for the
  -- IPython kernel).
  setEnv "JPY_PARENT_PID" "-1"

  -- Start the kernel, and then give it a bit of time to start. If we don't give it some time to
  -- start, then it is possible for it to miss our first message. In that case, this test suite just
  -- spins forever...
  case mkCmd filename of
    [] -> fail "Jupyter.Test.Client.startKernel: Expected command with at the executable name"
    cmd:args -> spawnProcess cmd args

-- | Run a kernel and a client connected to that kernel (in a temporary directory).
--
-- This function starts a client, uses the generated kernel profile to start an external kernel
-- process, connects to that kernel and communicates with it as indicated by a 'Client' action, and
-- then ensures that the external process is shutdown before exiting.
runKernelAndClient :: (KernelProfile -> IO ProcessHandle) -- ^ Function to start external kernel
                   -> ClientHandlers -- ^ Client handlers for messages received from the kernel
                   -> (KernelProfile -> ProcessHandle -> KernelConnection -> Client a) -- ^ Client action to run
                   -> IO a
runKernelAndClient start handlers action =
  inTempDir $ \_ -> runClient Nothing Nothing handlers $ \profile -> do
    proc <- liftIO $ start profile
    finally (connectKernel >>= action profile proc) $ liftIO $ terminateProcess proc

-- | Use the 'MessageExchange' data type to generate a test case for a test suite.
--
-- The generated test suite starts a kernel, acquires some basic information about it, sets up a
-- client that talks to the kernel, and plays through the 'MessageExchange's provided by the user.
-- All interactions between the client and the kernel are recorded and verified against the
-- 'MessageExchange's.
testMessageExchange :: String -- ^ Name for the test case
                    -> IO (FilePath -> [String])
                    -- ^ IO action that generates a function, which, if given the path to the
                    -- connection file, returns the full command to run. This is allowed to be
                    -- dynamically generated so that it can use 'findKernel' to locate external
                    -- kernels Jupyter knows about.
                    -> CodeBlock
                    -- ^ A valid block of code accepted by the kernel, used to establish
                    -- the execution counter and session number.
                    -> (Int -> ExecutionCount -> KernelProfile -> [MessageExchange])
                    -- ^ A function to generate the all desired message exchanges, given the
                    -- session number in progress (for history requests), the previous execution
                    -- request, and the profile that was connected to.
                    -> TestTree
testMessageExchange name mkKernelCommand validCode mkMessageExchanges = testCaseSteps name $ \step -> do
  -- All communication from kernel to client is recorded in MVars. Allocate those MVars
  -- and create the handlers that write to those MVars.
  kernelOutputsVar <- newMVar []
  commsVar <- newEmptyMVar
  kernelRequestsVar <- newEmptyMVar
  clientRepliesVar <- newMVar []
  let clientHandlers = ClientHandlers (exchangeKernelRequestHandler clientRepliesVar kernelRequestsVar)
                                      (exchangeCommHandler commsVar)
                                      (exchangeKernelOutputHandler kernelOutputsVar)

  mk <- mkKernelCommand
  runKernelAndClient (startKernel mk) clientHandlers $ \profile proc connection -> do
    -- Wait for the kernel to initialize. We know that the kernel is done initializing when it sends its
    -- first response; however, sometimes we also get a "starting" status. Since later on we check for
    -- equality of kernel outputs, we want to get rid of this timing inconsistencey immediately by just 
    -- doing on preparation message.
    liftIO $ step "Waiting for kernel to start..."
    void $ sendClientRequest connection ConnectRequest
    liftIO $ do
      waitForKernelIdle kernelOutputsVar
      void $ swapMVar kernelOutputsVar []

    -- Acquire the current session number. Without this, we can't accurately test the history replies,
    -- since they contain the session numbers. To acquire the session number, send an execute request followed
    -- by a history request.
    execReply <- sendClientRequest connection $ ExecuteRequest validCode defaultExecuteOptions
    execCount <- case execReply of
      ExecuteReply count _ -> return count
      _ -> fail "Expected ExecuteReply for ExecuteRequest"
    liftIO $ do
      waitForKernelIdle kernelOutputsVar
      void $ swapMVar kernelOutputsVar []

    histReply <- sendClientRequest connection $ HistoryRequest $ HistoryOptions False True $ HistoryTail 1
    sessionNum <- case histReply of
      HistoryReply items -> return $ maybe 1 historyItemSession (listToMaybe items)
      _ -> fail "Expected HistoryReply for HistoryRequest"
    liftIO $ waitForKernelIdle kernelOutputsVar
    void $ liftIO $ takeMVar kernelOutputsVar

    liftIO $ step "Checking messages exchanges..."
    forM_ (mkMessageExchanges sessionNum execCount profile) $ \MessageExchange{..} -> do
      liftIO $ do
        step $ "\t..." ++ exchangeName
        putMVar kernelOutputsVar []
        putMVar commsVar []
        putMVar kernelRequestsVar []

        void $ tryTakeMVar clientRepliesVar
        putMVar clientRepliesVar exchangeKernelRequests

      reply <- sendClientRequest connection exchangeRequest

      liftIO $ do
        waitForKernelIdle kernelOutputsVar
        exchangeReply @=? reply

        receivedComms <- takeMVar commsVar
        exchangeComms @=? reverse receivedComms

        receivedKernelRequests <- takeMVar kernelRequestsVar
        map fst exchangeKernelRequests  @=? reverse receivedKernelRequests

        receivedOutputs <- takeMVar kernelOutputsVar
        exchangeKernelOutputs @=? reverse receivedOutputs

        -- In the case of message exchanges that end in a shutdown message, the kernel is
        -- responsible for shutting itself down after it sends the shutdown reply. Test that
        -- this happens, and fail if the kernel process hasn't terminated after some time.
        case exchangeReply of
          ShutdownReply{} -> do
            threadDelay $ 500 * 1000
            exitCodeM <- getProcessExitCode proc
            case exitCodeM of
              Nothing -> assertFailure "Kernel did not shut down after shutdown request"
              _ -> return ()
          _ -> return ()

-- | Wait for the kernel to send a 'KernelIdle' status update.
--
-- This function polls the given 'MVar', waiting for the contained list to have a 'KernelIdle'
-- status update. If this doesn't happen within a fixed but long timeout (1 second or so), an
-- exception is raised, as it is likely indicative of a deadlock.
waitForKernelIdle :: MVar [KernelOutput] -> IO ()
waitForKernelIdle var = do
  res <- timeout 1000000 wait
  case res of
    Just _  -> return ()
    Nothing -> fail "Timed out in waitForKernelIdle: deadlock?"

  where
    -- Poll the MVar until it has the KernelIdle in it.
    wait = do
      outputs <- readMVar var
      unless (KernelStatusOutput KernelIdle `elem` outputs) $ do
        threadDelay 100000
        waitForKernelIdle var

-- | A fake UUID that replaces all UUIDs in 'Comm' messages.
--
-- This is necessary because kernels generate UUIDs randomly, so we cannot use equality to test
-- them, unless we replace all UUIDs with fake ones. This is the fake UUID that UUIDs from the
-- kernels get replaced with.
fakeUUID :: UUID.UUID
fakeUUID = UUID.uuidFromString "fake"

-- | A handler for the 'kernelRequestHandler' field of 'ClientHandlers'.
--
-- This handler listens for kernel requests, and, upon a kernel request, stores it in a list in an
-- MVar, and then looks up a response to this kernel request and replies with it.
--
-- This allows for scripted interactions between clients and kernels to include /stdin/ channel requests.
exchangeKernelRequestHandler :: MVar [(KernelRequest, ClientReply)]
                             -- ^ Variable holding request / response pairs; response is looked up here.
                             -> MVar [KernelRequest]
                             -- ^ Variable to store the received kernel request in.
                             -> (Comm -> IO ())
                             -- ^ (unused) callback to send 'Comm' messages to the kernel
                             -> KernelRequest
                             -- ^ Received kernel request
                             -> IO ClientReply
exchangeKernelRequestHandler repliesVar var _ req = do
  modifyMVar_ var $ return . (req :)
  replies <- readMVar repliesVar
  case lookup req replies of
    Just reply -> return reply
    Nothing    -> fail "Could not find appropriate client reply"


-- | A handler for the 'commHandler' field of 'ClientHandlers'.
--
-- This handler listens for comm messages and stores them into a mutable variable. All 'Comm'
-- messages have a UUID, which is replaced by 'fakeUUID'. In addition, any known fields that are
-- non-deterministic are dropped from the data. (For instance, 'layout' is dropped from IPython
-- widget messages.)
--
-- This function is not meant to be incredibly reusable.
exchangeCommHandler :: MVar [Comm] -> (Comm -> IO ()) -> Comm -> IO ()
exchangeCommHandler var _ comm = modifyMVar_ var $ return . (comm' :)
  where
    comm' =
      case comm of
        CommOpen _ val b c -> CommOpen fakeUUID (dropJSONKey "layout" val) b c
        CommClose _ a -> CommClose fakeUUID a
        CommMessage _ a -> CommMessage fakeUUID a

    dropJSONKey key val =
      case val of
        Object o -> Object (HashMap.delete key o)
        other -> other

-- | A handler for the 'kernelOutputHandler' field of 'ClientHandlers'.
--
-- This handler listens for 'KernelOutput' messages and stores them into a mutable variable.
exchangeKernelOutputHandler :: MVar [KernelOutput] -> (Comm -> IO ()) -> KernelOutput -> IO ()
exchangeKernelOutputHandler var _ out =
  modifyMVar_ var $ return . (out :)