salmon-ops-recipes-0.1.0.0: test/Test/ServeSocketSpec.hs
{-# LANGUAGE DeriveGeneric #-}
{- | Layer 1 coverage for "Salmon.Actions.Serve.Socket" (milestone 2 of
@specs\/generic-server.md@): the @run serve@ loop with a unix socket
listener beside its standard input, driven by real clients over real
connections.
What is under test is the plumbing, not the recipe — the nodes are the
same counter-bumping stubs 'Test.ServeModelSpec' uses. The claims: two
clients interleaving commands each read exactly the reports for their own
lines and nothing else, and the world they leave is the one the same
script typed on one stdin would leave; a client hanging up is reported and
does not end the loop; @quit@ from a client does; a client that half-closes
after typing still gets its reports; and the socket file is owner-only,
refused while something listens on it, and replaced when stale.
-}
module Test.ServeSocketSpec (tests) where
import Control.Concurrent (forkIO)
import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
import Control.Exception (bracket, try)
import Control.Monad (unless, when)
import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecodeStrict)
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Bits ((.&.))
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Char8 as Char8
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as Text
import GHC.Generics (Generic)
import qualified Network.Socket as Socket
import Network.Socket (Socket)
import qualified Network.Socket.ByteString as SocketBS
import System.FilePath ((</>))
import System.IO (Handle, IOMode (ReadMode), hClose, hPutStr, withFile)
import System.IO.Temp (withSystemTempFile)
import System.Posix.Files (fileMode, getFileStatus)
import System.Timeout (timeout)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
import qualified Salmon.Actions.Serve as Serve
import Salmon.Actions.Serve (Convergence (..), Direction (..), NodeState (..), World (..))
import qualified Salmon.Actions.Serve.Socket as Socket
import Salmon.Builtin.Extension (Track', deps, down, nodeps, op, ref, up)
import Salmon.Op.Configure (Configure (..))
import Salmon.Op.Ref (Ref, mkRef)
import Salmon.Op.Track (Track (..))
import Salmon.Reporter (silent)
import qualified Salmon.Reporter.Tagged as Tagged
import Test.Harness (capture, privatePipe, withTempDir)
tests :: TestTree
tests =
testGroup
"Salmon.Actions.Serve.Socket"
[ testCase "two clients interleaving commands each read only their own reports" twoClientsInterleave
, testCase "a client hanging up is reported and does not end the loop" hangUpDoesNotEndTheLoop
, testCase "`quit` from a client ends the loop" quitFromAClientEndsTheLoop
, testCase "a client that half-closes after typing still gets its reports" halfCloseStillAnswered
, testCase "the socket is owner-only, refused while live, replaced when stale, refused when too long" socketFileRules
]
-------------------------------------------------------------------------------
-- the thing served: counters per node name, as in Test.ServeModelSpec
newtype Spec = Spec {specNames :: [String]}
deriving (Eq, Show, Generic)
instance ToJSON Spec
instance FromJSON Spec
parseSpec :: [String] -> Either Text Spec
parseSpec [] = Left "expected at least one node name"
parseSpec args = Right (Spec args)
spyProgram :: IORef (Map String Int) -> IORef (Map String Int) -> Track' Spec
spyProgram upsRef downsRef = Track $ \spec ->
op "socket-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
actions{ref = mkRef "socket-root" spec.specNames}
where
nodeOp name =
op "socket-node" nodeps $ \actions ->
actions
{ ref = mkRef "socket-node" name
, up = bump upsRef name
, down = bump downsRef name
}
bump r name = atomicModifyIORef' r (\m -> (Map.insertWith (+) name 1 m, ()))
-------------------------------------------------------------------------------
-- a running loop with a listener
data Running = Running
{ runningPath :: FilePath
, runningStdin :: Handle
-- ^ the writing end of the loop's standard input; closing it ends the loop
, runningWorld :: MVar (World Spec Spec)
, runningOwn :: IO [Tagged.Tagged]
-- ^ everything the loop's own reporter saw
, runningUps :: IORef (Map String Int)
, runningDowns :: IORef (Map String Int)
}
{- | Start the loop on a temp socket with a pipe for standard input, hand
it to the test, and make sure it has ended before the temp dir goes.
-}
withRunning :: (Running -> IO a) -> IO a
withRunning act =
withTempDir $ \dir -> do
let path = dir </> "serve.sock"
(stdinR, stdinW) <- privatePipe
worldVar <- newEmptyMVar
(own, seen) <- capture
upsRef <- newIORef Map.empty
downsRef <- newIORef Map.empty
Socket.withUnixListener path $ \listener -> do
let (serveR, updownR) = Socket.listenerReporters listener own
_ <- forkIO $ do
w <-
Serve.serveAttributed
[]
Nothing
True
serveR
updownR
parseSpec
(Configure pure)
(spyProgram upsRef downsRef)
Nothing
[Serve.stdinProducer stdinR, Socket.listenerProducer listener]
putMVar worldVar w
let running = Running path stdinW worldVar seen upsRef downsRef
r <- act running
-- whatever the test did, the loop must be gone before the socket
-- and the directory are: close stdin (harmless if the loop
-- already ended on `quit`) and wait for it.
_ <- try (hClose stdinW) :: IO (Either IOError ())
_ <- awaitWorld running
pure r
awaitWorld :: Running -> IO (World Spec Spec)
awaitWorld running = do
mw <- timeout (10 * 1000000) (takeMVar (runningWorld running))
case mw of
Nothing -> assertFailure "the loop did not end"
Just w -> do
-- put it back so a second wait (withRunning's own) finds it
putMVar (runningWorld running) w
pure w
-------------------------------------------------------------------------------
-- a client: a raw socket, so a test can half-close it
data Client = Client
{ clientSocket :: Socket
, clientBuffer :: IORef ByteString.ByteString
, clientSeen :: IORef [Value]
-- ^ every object read so far, oldest first once reversed
}
withClient :: Running -> (Client -> IO a) -> IO a
withClient running = bracket (connectClient running) (Socket.close . clientSocket)
connectClient :: Running -> IO Client
connectClient running = do
sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
Socket.connect sock (Socket.SockAddrUnix (runningPath running))
Client sock <$> newIORef ByteString.empty <*> newIORef []
send :: Client -> String -> IO ()
send c line = SocketBS.sendAll (clientSocket c) (Char8.pack (line <> "\n"))
-- | One line off the connection, or 'Nothing' at end of file.
readLine :: Client -> IO (Maybe ByteString.ByteString)
readLine c = do
buf <- readIORef (clientBuffer c)
case Char8.elemIndex '\n' buf of
Just i -> do
let (line, rest) = ByteString.splitAt i buf
atomicModifyIORef' (clientBuffer c) (const (ByteString.drop 1 rest, ()))
pure (Just line)
Nothing -> do
chunk <- SocketBS.recv (clientSocket c) 4096
if ByteString.null chunk
then pure Nothing
else do
atomicModifyIORef' (clientBuffer c) (\b -> (b <> chunk, ()))
readLine c
-- | Read JSON objects until one of the given kind arrives, and return the
-- kinds read in this call, that one included.
readUntil :: Client -> Text -> IO [Text]
readUntil c wanted = do
r <- timeout (10 * 1000000) (go [])
case r of
Nothing -> do
seen <- readIORef (clientSeen c)
assertFailure ("timed out waiting for " <> Text.unpack wanted <> "; seen so far: " <> show (fmap kindOf (reverse seen)))
Just ks -> pure ks
where
go acc = do
mline <- readLine c
case mline of
Nothing -> assertFailure ("connection closed while waiting for " <> Text.unpack wanted <> "; got " <> show (reverse acc))
Just line ->
case eitherDecodeStrict line of
Left err -> assertFailure ("not a JSON line: " <> err <> ": " <> Char8.unpack line)
Right v -> do
atomicModifyIORef' (clientSeen c) (\vs -> (v : vs, ()))
let k = kindOf v
if k == wanted then pure (reverse (k : acc)) else go (k : acc)
-- | Send a line and read its reports through to the one that ends them.
ask :: Client -> String -> Text -> IO [Text]
ask c line lastKind = send c line >> readUntil c lastKind
kindOf :: Value -> Text
kindOf (Object o) = case KeyMap.lookup "kind" o of
Just (String k) -> k
_ -> "<no kind>"
kindOf _ = "<not an object>"
kindsSeen :: Client -> IO [Text]
kindsSeen c = fmap kindOf . reverse <$> readIORef (clientSeen c)
-------------------------------------------------------------------------------
{- | The script both halves of the comparison run: A declares and asks for
history, B asks for status and retires; each waits for the previous
command's last report before the next client types, so the inbox order is
the script order.
-}
twoClientsInterleave :: IO ()
twoClientsInterleave = do
(sequentialUps, sequentialDowns, sequentialWorld) <- runScript script
withRunning $ \running -> do
withClient running $ \a -> withClient running $ \b -> do
_ <- ask a "supervise off" "supervised"
_ <- ask a "up n1" "converge-stop"
_ <- ask b "status" "status"
_ <- ask a "up n1 n2" "converge-stop"
_ <- ask b "status" "status"
_ <- ask b "down n1" "converge-stop"
_ <- ask a "history" "history"
aKinds <- kindsSeen a
bKinds <- kindsSeen b
-- A never asked for status; B never declared, asked for history,
-- or changed supervision
assertBool ("A read a status that B asked for: " <> show aKinds) ("status" `notElem` aKinds)
assertBool ("B read something only A asked for: " <> show bKinds) $
not (any (`elem` ["supervised", "history"]) bKinds)
-- and each read the shape its own lines produce
assertEqual "A's first report" (Just "supervised") (headMay aKinds)
assertEqual "A's last report" (Just "history") (lastMay aKinds)
assertEqual "B's first two reports" ["status", "status"] (take 2 bKinds)
assertEqual "B's last report" (Just "converge-stop") (lastMay bKinds)
assertEqual "declarations A read" 2 (length (filter (== "declared") aKinds))
assertEqual "declarations B read" 1 (length (filter (== "declared") bKinds))
-- neither read anything the loop said outside a command
assertBool "no hang-up reached a client" (all (`notElem` ["hung-up", "started", "stopped"]) (aKinds <> bKinds))
-- the loop's own reporter saw every one of those reports too,
-- and nothing more than its own bookends
own <- runningOwn running
let ownKinds = fmap kindOf' own
assertEqual
"the loop's own reporter saw each client's reports"
(length aKinds + length bKinds)
(length (filter (`notElem` ["started", "stopped", "hung-up"]) ownKinds))
hClose (runningStdin running)
w <- awaitWorld running
ups <- readIORef (runningUps running)
downs <- readIORef (runningDowns running)
assertEqual "ups agree with the one-stdin run" sequentialUps ups
assertEqual "downs agree with the one-stdin run" sequentialDowns downs
assertEqual "the world agrees with the one-stdin run" (worldShape sequentialWorld) (worldShape w)
where
script = ["supervise off", "up n1", "status", "up n1 n2", "status", "down n1", "history"]
headMay xs = case xs of
[] -> Nothing
(x : _) -> Just x
lastMay xs = case xs of
[] -> Nothing
_ -> Just (last xs)
kindOf' t = case t of
Tagged.FromServe rep -> case rep of
Serve.Started -> "started"
Serve.Stopped -> "stopped"
Serve.HungUp{} -> "hung-up"
_ -> "serve"
_ -> "node"
hangUpDoesNotEndTheLoop :: IO ()
hangUpDoesNotEndTheLoop =
withRunning $ \running -> do
withClient running $ \a -> do
_ <- ask a "supervise off" "supervised"
_ <- ask a "up n1" "converge-stop"
pure ()
-- A is gone. The loop says so on its own reporter, once its lines
-- are handled, and keeps going.
waitFor "the hang-up to be reported" $ do
own <- runningOwn running
pure (any isHangUp own)
withClient running $ \b -> do
_ <- ask b "status" "status"
seen <- readIORef (clientSeen b)
case seen of
[Object o] -> case KeyMap.lookup "nodes" o of
Just (Array nodes) -> assertEqual "B still sees A's nodes" 2 (length nodes)
_ -> assertFailure "status without nodes"
_ -> assertFailure ("B expected exactly one status object, got " <> show seen)
-- B's hang-up too, before stdin ends the loop: the two 'Eof's are
-- pushed by different threads and would otherwise race.
waitFor "the second hang-up" $ do
own <- runningOwn running
pure (length (filter isHangUp own) == 2)
hClose (runningStdin running)
_ <- awaitWorld running
own <- runningOwn running
assertEqual "hang-ups reported" 2 (length (filter isHangUp own))
assertBool "the loop ended on stdin's end of input" (any isStopped own)
where
isHangUp t = case t of
Tagged.FromServe Serve.HungUp{} -> True
_ -> False
isStopped t = case t of
Tagged.FromServe Serve.Stopped -> True
_ -> False
quitFromAClientEndsTheLoop :: IO ()
quitFromAClientEndsTheLoop =
withRunning $ \running -> do
withClient running $ \a -> do
_ <- ask a "up n1" "converge-stop"
send a "quit"
-- standard input is still open: only the client's quit can end this
w <- awaitWorld running
assertEqual "the declaration survived" 1 (Map.size (worldLedger w))
-- the loop is gone and so is the connection
rest <- timeout (10 * 1000000) (readLine a)
assertEqual "the client reads end of file" (Just Nothing) rest
own <- runningOwn running
assertBool "quit is not stdin closing" (not (any isStopped own))
where
isStopped t = case t of
Tagged.FromServe Serve.Stopped -> True
_ -> False
halfCloseStillAnswered :: IO ()
halfCloseStillAnswered =
withRunning $ \running ->
withClient running $ \a -> do
send a "supervise off"
send a "up n1 n2"
send a "status"
-- nothing more to say, and the loop has not necessarily read a
-- word of it yet
Socket.shutdown (clientSocket a) Socket.ShutdownSend
kinds <- readUntil a "status"
assertBool ("all three commands were answered: " <> show kinds) $
all (`elem` kinds) ["supervised", "declared", "converge-stop", "status"]
-- and then the loop closes the connection, having said hung-up
-- on its own reporter only
rest <- timeout (10 * 1000000) (readLine a)
assertEqual "the connection is closed after the last report" (Just Nothing) rest
own <- runningOwn running
assertBool "the hang-up was reported" (any isHangUp own)
where
isHangUp t = case t of
Tagged.FromServe Serve.HungUp{} -> True
_ -> False
socketFileRules :: IO ()
socketFileRules =
withTempDir $ \dir -> do
let path = dir </> "rules.sock"
Socket.withUnixListener path $ \_ -> do
st <- getFileStatus path
assertEqual "owner-only" 0o600 (fileMode st .&. 0o777)
r <- try (Socket.withUnixListener path (const (pure ())))
assertEqual "a live socket is refused" (Left (Socket.AlreadyListening path)) r
-- a stale socket file: bound once, never listened on again
stale <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
Socket.bind stale (Socket.SockAddrUnix path)
Socket.close stale
replaced <- try (Socket.withUnixListener path (const (pure ())))
assertEqual "a stale socket file is replaced" (Right ()) (replaced :: Either Socket.ListenError ())
-- a file that is not a socket is nobody's to remove
writeFile path "not a socket\n"
notSock <- try (Socket.withUnixListener path (const (pure ())))
assertEqual "a regular file is refused" (Left (Socket.NotASocket path)) notSock
-- the longest path a unix address holds binds; one character more is
-- refused by name, not by network's crash from inside bind
let sized n = dir </> replicate (n - length dir - 1) 'x'
longest = sized (Socket.unixPathMax - 1)
tooLong = sized Socket.unixPathMax
fits <- try (Socket.withUnixListener longest (const (pure ())))
assertEqual "the longest path binds" (Right ()) (fits :: Either Socket.ListenError ())
over <- try (Socket.withUnixListener tooLong (const (pure ())))
assertEqual "one more is refused" (Left (Socket.PathTooLong tooLong Socket.unixPathMax (Socket.unixPathMax - 1))) over
-------------------------------------------------------------------------------
waitFor :: String -> IO Bool -> IO ()
waitFor what cond = do
r <- timeout (10 * 1000000) go
when (r == Nothing) (assertFailure ("timed out waiting for " <> what))
where
go = do
ok <- cond
unless ok go
-- | The same script on one stdin, for the comparison.
runScript :: [String] -> IO (Map String Int, Map String Int, World Spec Spec)
runScript script = do
upsRef <- newIORef Map.empty
downsRef <- newIORef Map.empty
w <- withScript script $ \h ->
Serve.serveWith [] Nothing True silent silent parseSpec (Configure pure) (spyProgram upsRef downsRef) h
(,,) <$> readIORef upsRef <*> readIORef downsRef <*> pure w
withScript :: [String] -> (Handle -> IO a) -> IO a
withScript ls act =
withSystemTempFile "salmon-serve-socket-script" $ \path h -> do
hPutStr h (unlines ls)
hClose h
withFile path ReadMode act
-- | The comparable part of a 'World', as 'Test.ServeModelSpec' takes it.
worldShape :: World Spec Spec -> (Map Ref (Direction, Convergence), Set.Set Ref, Int, Int, Int)
worldShape w =
( Map.map (\st -> (st.nodeDirection, st.nodeConvergence)) w.worldNodes
, Map.keysSet w.worldMagma
, Map.size w.worldLedger
, length w.worldEpochs
, length w.worldLog
)