packages feed

reflex-vty-1.3.0.0: test/Reflex/Vty/Host/SignalSpec.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- |
-- Description: OS signal installation, mapping, and restoration.
--
-- Signal handlers are process-global state. The delivery scenarios
-- therefore run in isolated child processes of this test executable.
-- @test/Spec.hs@ dispatches the children via @REFLEX_VTY_SIGNAL_CHILD@
-- before Hspec starts. The parent only spawns children, raises
-- signals at them, and checks the reported sequence; it never
-- installs handlers itself. POSIX children verify real signal
-- delivery, mapping, and restoration. Windows children exercise
-- console-handler mapping and restoration. They have no desktop
-- console and no real OS shutdown, so they prove installation,
-- mapping, and restoration, not OS delivery.
module Reflex.Vty.Host.SignalSpec
  ( spec
  , runSignalChild
  ) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Async (waitCatch)
import Control.Concurrent.MVar
import Control.Exception (SomeException, bracketOnError, catch)
import Control.Monad (forM_, forever)
import qualified Data.ByteString.Char8 as BS
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.List (isPrefixOf)
import System.Environment (getEnvironment, getExecutablePath, lookupEnv)
import System.Exit (ExitCode (..), exitFailure, exitSuccess)
import System.FilePath ((</>))
import System.IO
import System.Process
import System.Timeout (timeout)
import Test.Hspec

#if defined(mingw32_HOST_OS)
import qualified GHC.ConsoleHandler as Console
#else
import System.Posix.Signals
  ( Handler (Catch)
  , Signal
  , installHandler
  , raiseSignal
  , sigHUP
  , sigINT
  , sigTERM
  , signalProcess
  )
#endif

import Control.Monad.IO.Class (liftIO)
import qualified Graphics.Vty as V
import Reflex

import Reflex.Vty.Host (AppSignal (..), VtyApp, VtyResult (..), defaultVtyAppConfig)
#if defined(mingw32_HOST_OS)
import Reflex.Vty.Host.Signal (installAppSignalHandlers)
#endif
import Reflex.Vty.Test.AppHarness (defaultRecordingVtyConfig, recordingVty, runHostAsync)

-- | Deadline for one expected line from a child (microseconds).
lineDeadline :: Int
lineDeadline = 10 * 1000000

-- | Background reader that turns a handle into a line mailbox.
startLineReader :: Handle -> IO (MVar (Maybe BS.ByteString))
startLineReader h = do
  v <- newEmptyMVar
  _ <-
    forkIO $
      (forever (BS.hGetLine h >>= putMVar v . Just))
        `catch` \(_ :: SomeException) -> putMVar v Nothing
  pure v

-- | Read the next line with a deadline. A deadline expiry and the
-- end of output are both reported as 'Nothing'.
nextLine :: MVar (Maybe BS.ByteString) -> IO (Maybe String)
nextLine v = do
  r <- timeout lineDeadline (takeMVar v)
  pure $ case r of
    Nothing -> Nothing
    Just Nothing -> Nothing
    Just (Just bs) -> Just (BS.unpack bs)

-- | Read lines until one equals the expected string.
awaitLine :: MVar (Maybe BS.ByteString) -> String -> IO ()
awaitLine v expected = do
  ml <- nextLine v
  case ml of
    Nothing -> expectationFailure ("child closed stdout while waiting for " ++ show expected) >> pure ()
    Just l
      | l == expected -> pure ()
      | otherwise -> awaitLine v expected

-- | Read lines until one starts with the prefix and return it.
awaitPrefix :: MVar (Maybe BS.ByteString) -> String -> IO String
awaitPrefix v prefix = do
  ml <- nextLine v
  case ml of
    Nothing -> expectationFailure ("child closed stdout while waiting for prefix " ++ show prefix) >> pure ""
    Just l
      | prefix `isPrefixOf` l -> pure l
      | otherwise -> awaitPrefix v prefix

-- | The child modes exercised on this platform.
childModes :: [(String, String, String)]
#if defined(mingw32_HOST_OS)
childModes = [("console", "", "")]
#else
childModes =
  [ ("int", "AppSignal_Interrupt", "sigINT")
  , ("term", "AppSignal_Terminate", "sigTERM")
  , ("hup", "AppSignal_Hangup", "sigHUP")
  ]
#endif

spec :: Spec
spec = describe "Reflex.Vty.Host.Signal" $ do
  forM_ childModes $ \(mode, mapped, _signalName) ->
    it ("installs, maps, and restores handlers for " ++ mode ++ " in an isolated child") $
      runChildScenario mode mapped

-- | Spawn this executable as a signal child, drive the expected
-- sequence, and verify clean termination.
runChildScenario :: String -> String -> IO ()
runChildScenario mode mapped = do
  exe <- getExecutablePath
  parentEnv <- getEnvironment
  tixOverride <- coverageChildTix mode
  let childEnv = (tixOverride ++) . (("REFLEX_VTY_SIGNAL_CHILD", mode) :) $ parentEnv
  bracketOnError
    -- The child gets its own process group. No signal in these
    -- scenarios can then reach the pane or session that runs the
    -- suite.
    ( createProcess
        (proc exe []) {env = Just childEnv, std_out = CreatePipe, create_group = True}
    )
    (\(_, _, _, ph) -> terminateProcess ph >> waitForProcess ph)
    $ \(mIn, mOut, _, ph) -> do
      case mOut of
        Nothing -> expectationFailure "child stdout was not piped"
        Just hOut -> do
          hSetBinaryMode hOut True
          hSetBuffering hOut LineBuffering
          v <- startLineReader hOut
          awaitLine v "READY"
          checkSignalDelivery ph v mode mapped
          ec <- waitForProcess ph
          ec `shouldBe` ExitSuccess

checkSignalDelivery :: ProcessHandle -> MVar (Maybe BS.ByteString) -> String -> String -> IO ()
#if !defined(mingw32_HOST_OS)
checkSignalDelivery ph v mode mapped = do
  mpid <- getPid ph
  case mpid of
    Nothing -> expectationFailure "could not get child pid"
    Just pid -> do
      signalProcess (childSignal mode) pid
      appLine <- awaitPrefix v "APP "
      appLine `shouldBe` ("APP " ++ mapped)
      awaitLine v "HOSTDONE"
      -- After host shutdown, delivery must reach the prior handler.
      signalProcess (childSignal mode) pid
      awaitLine v "PRIOR"
#else
checkSignalDelivery _ v _ _ = do
  forM_ consoleEvents $ \(_, expected) -> do
    l <- awaitPrefix v "APP "
    l `shouldBe` ("APP " ++ show expected)
  awaitLine v "COUNT 1"
  awaitLine v "HOSTDONE"
#endif

#if defined(mingw32_HOST_OS)
consoleEvents :: [(Console.ConsoleEvent, AppSignal)]
consoleEvents =
  [ (Console.ControlC, AppSignal_Interrupt)
  , (Console.Break, AppSignal_Interrupt)
  , (Console.Close, AppSignal_Hangup)
  , (Console.Logoff, AppSignal_Hangup)
  , (Console.Shutdown, AppSignal_Terminate)
  ]
#else
childSignal :: String -> Signal
childSignal mode = case mode of
  "int" -> sigINT
  "hup" -> sigHUP
  _ -> sigTERM
#endif

-- | The tix-file override for coverage runs: each child gets its own
-- file so children never overwrite the parent's tix.
coverageChildTix :: String -> IO [(String, String)]
coverageChildTix mode = do
  mdir <- lookupEnv "REFLEX_VTY_COVERAGE_CHILD_DIR"
  pure $ case mdir of
    Nothing -> []
    Just d -> [("HPCTIXFILE", d </> ("signal-" ++ mode ++ ".tix"))]

----------------------------------------------------------------------------
-- Child process implementation
----------------------------------------------------------------------------

-- | Entry point for child modes, dispatched from test/Spec.hs before
-- Hspec starts.
runSignalChild :: IO ()
runSignalChild = do
  hSetBuffering stdout LineBuffering
  hSetNewlineMode stdout noNewlineTranslation
  mmode <- lookupEnv "REFLEX_VTY_SIGNAL_CHILD"
  case mmode of
    Nothing -> putStrLn "CHILDERR no mode" >> exitFailure
#if defined(mingw32_HOST_OS)
    Just _ -> windowsConsoleChild
#else
    Just mode -> posixSignalChild mode
#endif

#if !defined(mingw32_HOST_OS)

posixSignalChild :: String -> IO ()
posixSignalChild mode = do
  let sig = childSignal mode
  priorDone <- newEmptyMVar
  _ <- installHandler sig (Catch (putStrLn "PRIOR" >> putMVar priorDone ())) Nothing
  (vty, _st) <- recordingVty defaultRecordingVtyConfig
  hostA <- runHostAsync defaultVtyAppConfig vty signalGuest
  r <- waitCatch hostA
  case r of
    Left e -> putStrLn ("HOSTFAIL " ++ show e) >> exitFailure
    Right () -> pure ()
  putStrLn "HOSTDONE"
  raiseSignal sig
  -- The restored prior handler signals completion from its own
  -- thread.
  done <- timeout (5 * 1000000) (takeMVar priorDone)
  case done of
    Nothing -> putStrLn "CHILDERR prior handler never ran" >> exitFailure
    Just () -> exitSuccess

-- | The guest reports readiness at post-build. The host installs
-- signal handlers before post-build. The guest reports each received
-- application signal and shuts down on the first one.
signalGuest :: VtyApp t m
signalGuest _dr _inp sigs = do
  pb <- getPostBuild
  performEvent_ $ ffor pb $ \_ -> liftIO (putStrLn "READY")
  performEvent_ $ ffor sigs $ \s -> liftIO (putStrLn ("APP " ++ show s))
  return
    VtyResult
      { _vtyResult_picture = constant (V.picForImage V.emptyImage)
      , _vtyResult_shutdown = () <$ sigs
      }

#else

-- | Windows child scenario. Install a counting handler. Install the
-- library's handler. Retrieve and invoke its callback for each
-- console event to observe the mapping. Restore all handlers. Verify
-- that the counting handler is active again.
windowsConsoleChild :: IO ()
windowsConsoleChild = do
  countRef <- newIORef (0 :: Int)
  _ <- Console.installHandler (Console.Catch (\_ -> modifyIORef' countRef (+1)))
  restore <- installAppSignalHandlers $ \s ->
    putStrLn ("APP " ++ show s)
  putStrLn "READY"
  -- Temporarily retrieve the active (library) handler; invoking its
  -- callback with each console event exercises the mapping without a
  -- desktop console or a real shutdown.
  active <- Console.installHandler (Console.Catch (\_ -> pure ()))
  case active of
    Console.Catch act ->
      forM_ consoleEvents (act . fst)
    _ -> pure ()
  _ <- Console.installHandler active -- restore the retrieved handler
  restore -- restore the counting handler
  -- The counting handler is active again.
  counting <- Console.installHandler (Console.Catch (\_ -> pure ()))
  case counting of
    Console.Catch act -> act Console.ControlC
    _ -> pure ()
  _ <- Console.installHandler counting
  n <- readIORef countRef
  putStrLn ("COUNT " ++ show n)
  putStrLn "HOSTDONE"

#endif