packages feed

reflex-vty-1.3.0.0: test/Reflex/Vty/HostSpec.hs

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

-- |
-- Description: Host lifecycle, backpressure, and failure semantics.
--
-- All scenarios run the real 'Reflex.Vty.Host.runVtyAppWithHandle'
-- against the recording terminal from "Reflex.Vty.Test.AppHarness".
-- Host tests are serial because signal handlers are process-global.
module Reflex.Vty.HostSpec (spec) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Async (Async, cancel, waitCatch)
import Control.Concurrent.MVar
import Control.Exception (SomeException, throwIO, try)
import Control.Monad (forM_, replicateM_, when)
import Control.Monad.IO.Class (liftIO)
import Data.IORef
import Data.List (isInfixOf)
import qualified Graphics.Vty as V
import Reflex
import System.Timeout (timeout)
import Test.Hspec

#if !defined(mingw32_HOST_OS)
import System.Posix.Signals (Handler (Catch), installHandler, sigUSR1)
#endif

import Reflex.Vty.Host
import Reflex.Vty.Test.AppHarness
  ( RecordingVtyConfig (..)
  , defaultRecordingVtyConfig
  , recordingLogHistory
  , recordingReleaseGate
  , recordingVty
  , runHostAsync
  )

-- | Burst for the drain regression: far beyond what a
-- one-redraw-per-batch loop could finish inside 'defaultDeadline'.
burstN :: Int
burstN = 10000

-- | Occurrence-preservation burst, deliberately far beyond the queue
-- capacity used in that test so backpressure provably engages.
overloadN :: Int
overloadN = 50000

-- | Deadlines (microseconds).
defaultDeadline :: Int
defaultDeadline = 10 * 1000000

slowDeadline :: Int
slowDeadline = 30 * 1000000

-- | Count exact log-marker occurrences.
countIn :: String -> [String] -> Int
countIn tag logs = length [() | l <- logs, l == tag]

-- | An empty-picture result that shuts down on the given event.
emptyPictureResult :: Reflex t => Event t () -> VtyResult t
emptyPictureResult shutdown =
  VtyResult
    { _vtyResult_picture = constant (V.picForImage V.emptyImage)
    , _vtyResult_shutdown = shutdown
    }

-- | Build a guest with an external tick trigger and a stop trigger.
-- Hand both fire functions to the test through the 'MVar' during
-- guest construction. The result builder receives the tick and stop
-- events.
triggerGuest
  :: MVar (() -> IO () -> IO (), () -> IO () -> IO ())
  -> (forall t. Reflex t => Event t () -> Event t () -> VtyResult t)
  -> (forall t m. VtyApp t m)
triggerGuest ready mkResult _dr _inp _sigs = do
  (tickE, fireTick) <- newTriggerEventWithOnComplete
  (stopE, fireStop) <- newTriggerEventWithOnComplete
  -- Subscribe to the tick so occurrences do real frame work.
  performEvent_ $ ffor tickE $ \() -> pure ()
  liftIO $ putMVar ready (fireTick, fireStop)
  return (mkResult tickE stopE)

-- | Expect the host to finish successfully within the deadline;
-- cancel it and fail with diagnostics otherwise.
expectHostOk :: Async () -> [String] -> IO ()
expectHostOk hostA logs = do
  r <- timeout slowDeadline (waitCatch hostA)
  case r of
    Nothing -> do
      cancel hostA
      expectationFailure
        ( "host did not terminate within deadline; log tail: "
            ++ show (drop (max 0 (length logs - 40)) logs)
        )
    Just (Left e) ->
      expectationFailure
        ( "host failed: "
            ++ show e
            ++ "; log tail: "
            ++ show (drop (max 0 (length logs - 40)) logs)
        )
    Just (Right ()) -> pure ()

spec :: Spec
spec = describe "Reflex.Vty.Host" $ do
  describe "runVtyAppWithHandle" $ do
    it "drains a burst of external triggers in bounded frames (regression)" $ do
      -- The old host drew once per queued batch and could not reach
      -- shutdown inside the deadline; the draining host redraws once
      -- per drain.
      ready <- newEmptyMVar
      let cfg = defaultVtyAppConfig
          guest :: VtyApp t m
          guest = triggerGuest ready (\_tick stop -> emptyPictureResult stop)
      (vty, st) <- recordingVty defaultRecordingVtyConfig
      hostA <- runHostAsync cfg vty guest
      (fireTick, fireStop) <- takeMVar ready
      _ <- forkIO $ replicateM_ burstN (fireTick () (pure ())) >> fireStop () (pure ())
      logsAfter <- recordingLogHistory st
      expectHostOk hostA logsAfter
      logs <- recordingLogHistory st
      countIn "draw" logs `shouldSatisfy` (< 100)

    it "preserves every occurrence of an overloaded external trigger (backpressure)" $ do
      counter <- newIORef (0 :: Int)
      ready <- newEmptyMVar
      let cfg = defaultVtyAppConfig {_vtyConfig_eventQueueCapacity = 16}
          guest :: VtyApp t m
          guest = triggerGuest ready (\_tick stop -> emptyPictureResult stop)
      (vty, st) <- recordingVty defaultRecordingVtyConfig
      hostA <- runHostAsync cfg vty guest
      (fireTick, fireStop) <- takeMVar ready
      _ <- forkIO $ do
        replicateM_ overloadN $ fireTick () (atomicModifyIORef' counter (\i -> (i + 1, ())))
        fireStop () (pure ())
      logsAfter <- recordingLogHistory st
      expectHostOk hostA logsAfter
      readIORef counter `shouldReturn` overloadN

    it "drains a gated, capacity-filling burst with bounded redraws" $ do
      ready <- newEmptyMVar
      let cfg = defaultVtyAppConfig {_vtyConfig_eventQueueCapacity = 10001}
          guest :: VtyApp t m
          guest = triggerGuest ready (\_tick stop -> emptyPictureResult stop)
      (vty, st) <- recordingVty defaultRecordingVtyConfig {recordingGateRedraw = True}
      hostA <- runHostAsync cfg vty guest
      (fireTick, fireStop) <- takeMVar ready
      -- The initial redraw is gated. Enqueue the entire finite burst
      -- and the shutdown: 10,001 writes fit the 10,001-capacity queue,
      -- so the producer cannot deadlock against the gate.
      forM_ [1 .. 10000 :: Int] $ \_ -> fireTick () (pure ())
      fireStop () (pure ())
      recordingReleaseGate st
      logsAfter <- recordingLogHistory st
      expectHostOk hostA logsAfter
      logs <- recordingLogHistory st
      countIn "draw" logs `shouldSatisfy` (<= 3)

    it "terminates immediately on post-build shutdown without drawing" $ do
      -- Regression: post-build shutdown used to be lost (the shutdown
      -- subscription happened after the post-build fire), leaving the
      -- host blocked on input forever.
      let guest :: VtyApp t m
          guest _dr _inp _sigs = do
            pb <- getPostBuild
            return (emptyPictureResult pb)
      (vty, st) <- recordingVty defaultRecordingVtyConfig
      hostA <- runHostAsync defaultVtyAppConfig vty guest
      r <- timeout defaultDeadline (waitCatch hostA)
      case r of
        Nothing -> cancel hostA >> expectationFailure "host did not terminate at post-build shutdown"
        Just (Left e) -> expectationFailure ("host failed: " ++ show e)
        Just (Right ()) -> pure ()
      logs <- recordingLogHistory st
      countIn "draw" logs `shouldBe` 0 -- no unnecessary frame was drawn
    it "terminates immediately when a post-build perform-event requests shutdown" $ do
      let guest :: VtyApp t m
          guest _dr _inp _sigs = do
            (stopE, fireStop) <- newTriggerEventWithOnComplete
            pb <- getPostBuild
            performEvent_ $ ffor pb $ \_ -> liftIO (fireStop () (pure ()))
            return (emptyPictureResult (() <$ stopE))
      (vty, st) <- recordingVty defaultRecordingVtyConfig
      hostA <- runHostAsync defaultVtyAppConfig vty guest
      r <- timeout defaultDeadline (waitCatch hostA)
      case r of
        Nothing -> cancel hostA >> expectationFailure "host did not terminate on perform-event shutdown"
        Just (Left e) -> expectationFailure ("host failed: " ++ show e)
        Just (Right ()) -> pure ()
      logs <- recordingLogHistory st
      -- The stop request travels through the external-trigger queue.
      -- The host therefore draws at most the initial picture, never
      -- starts the input pump, and runs no interactive frame.
      countIn "draw" logs `shouldSatisfy` (<= 1)

    it "runs processed trigger callbacks exactly once, without subscribers" $ do
      callbacks <- newIORef (0 :: Int)
      ready <- newEmptyMVar
      let guest :: VtyApp t m
          guest _dr _inp _sigs = do
            (fireE, fire) <- newTriggerEventWithOnComplete
            (stopE, fireStop) <- newTriggerEventWithOnComplete
            -- Nobody subscribes to fireE; its occurrences must still
            -- run their completion callbacks exactly once each when
            -- processed.
            liftIO $ putMVar ready (fire, fireStop)
            return (emptyPictureResult (() <$ stopE))
      (vty, _) <- recordingVty defaultRecordingVtyConfig
      hostA <- runHostAsync defaultVtyAppConfig vty guest
      (fire, fireStop) <- takeMVar ready
      let bump = atomicModifyIORef' callbacks (\i -> (i + 1, ()))
      fire () bump
      fire () bump
      fire () bump
      fireStop () (pure ())
      r <- timeout defaultDeadline (waitCatch hostA)
      case r of
        Nothing -> cancel hostA >> expectationFailure "host did not terminate"
        Just (Left e) -> expectationFailure ("host failed: " ++ show e)
        Just (Right ()) -> pure ()
      readIORef callbacks `shouldReturn` 3

    describe "failure scenarios" $ do
      it "propagates guest construction failure and shuts vty down once" $ do
        let guest :: VtyApp t m
            guest _dr _inp _sigs = liftIO (ioError (userError "guest-boom"))
        (vty, st) <- recordingVty defaultRecordingVtyConfig
        r <- timeout defaultDeadline (try (runVtyAppWithHandle defaultVtyAppConfig vty guest) :: IO (Either SomeException ()))
        case r of
          Nothing -> expectationFailure "host did not fail fast"
          Just (Left e) -> show e `shouldContain` "guest-boom"
          Just (Right ()) -> expectationFailure "expected the guest failure to propagate"
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "propagates initial update failure" $ do
        ready <- newEmptyMVar
        let guest :: VtyApp t m
            guest = triggerGuest ready (\_tick _stop -> emptyPictureResult never)
        (vty, st) <-
          recordingVty
            defaultRecordingVtyConfig
              { recordingOnPicture = \_ -> throwIO (userError "update-boom")
              }
        hostA <- runHostAsync defaultVtyAppConfig vty guest
        r <- timeout defaultDeadline (waitCatch hostA)
        case r of
          Nothing -> cancel hostA >> expectationFailure "host did not fail fast"
          Just (Left e) -> show e `shouldContain` "update-boom"
          Just (Right ()) -> expectationFailure "expected the update failure to propagate"
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "propagates a later update failure" $ do
        ready <- newEmptyMVar
        drawRef <- newIORef (0 :: Int)
        let guest :: VtyApp t m
            guest = triggerGuest ready (\_tick stop -> emptyPictureResult stop)
        (vty, st) <-
          recordingVty
            defaultRecordingVtyConfig
              { recordingOnPicture = \_ -> do
                  n <- atomicModifyIORef' drawRef (\i -> (i + 1, i + 1))
                  when (n >= 2) $ throwIO (userError "later-update-boom")
              }
        hostA <- runHostAsync defaultVtyAppConfig vty guest
        (fireTick, _) <- takeMVar ready
        -- One tick forces the next drain's redraw. The guest never
        -- shuts down, so the host must make that redraw (and its
        -- failure) happen.
        fireTick () (pure ())
        r <- timeout defaultDeadline (waitCatch hostA)
        case r of
          Nothing -> cancel hostA >> expectationFailure "host did not fail fast"
          Just (Left e) -> show e `shouldContain` "later-update-boom"
          Just (Right ()) -> expectationFailure "expected the later update failure to propagate"
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "propagates input-reader failure" $ do
        let guest :: VtyApp t m
            guest _dr _inp _sigs = return (emptyPictureResult never)
        (vty0, st) <- recordingVty defaultRecordingVtyConfig
        let vty = vty0 {V.nextEvent = throwIO (userError "input-boom")}
        hostA <- runHostAsync defaultVtyAppConfig vty guest
        r <- timeout defaultDeadline (waitCatch hostA)
        case r of
          Nothing -> cancel hostA >> expectationFailure "input failure did not propagate"
          Just (Left e) -> show e `shouldContain` "input-boom"
          Just (Right ()) -> expectationFailure "expected input failure to fail the host"
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "propagates perform-event failure" $ do
        let guest :: VtyApp t m
            guest _dr _inp _sigs = do
              pb <- getPostBuild
              performEvent_ $ ffor pb $ \_ -> liftIO (throwIO (userError "perform-boom"))
              return (emptyPictureResult never)
        (vty, st) <- recordingVty defaultRecordingVtyConfig
        r <- timeout defaultDeadline (try (runVtyAppWithHandle defaultVtyAppConfig vty guest) :: IO (Either SomeException ()))
        case r of
          Nothing -> expectationFailure "host did not fail fast"
          Just (Left e) -> show e `shouldContain` "perform-boom"
          Just (Right ()) -> expectationFailure "expected perform-event failure to propagate"
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "releases a backpressured producer on external cancellation" $ do
        producerDone <- newEmptyMVar
        ready <- newEmptyMVar
        let cfg = defaultVtyAppConfig {_vtyConfig_eventQueueCapacity = 1}
            guest :: VtyApp t m
            guest = triggerGuest ready (\_tick stop -> emptyPictureResult stop)
        (vty, st) <- recordingVty defaultRecordingVtyConfig
        hostA <- runHostAsync cfg vty guest
        (fireTick, _) <- takeMVar ready
        _ <- forkIO $ do
          -- Fires far more than the capacity: it backpressures until
          -- cancellation closes the queue, then finishes.
          replicateM_ 100 (fireTick () (pure ()))
          putMVar producerDone ()
        threadDelay 200000 -- let the producer hit the full queue
        cancel hostA
        done <- timeout defaultDeadline (takeMVar producerDone)
        done `shouldBe` Just ()
        _ <- waitCatch hostA
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "prefers the application failure over a cleanup failure, still running later cleanup" $ do
        let guest :: VtyApp t m
            guest _dr _inp _sigs = liftIO (ioError (userError "app-boom"))
        (vty0, st) <- recordingVty defaultRecordingVtyConfig
        let vty =
              vty0
                { V.outputIface =
                    (V.outputIface vty0)
                      { V.outputByteBuffer = \_ -> throwIO (userError "cleanup-boom")
                      }
                }
        r <- timeout defaultDeadline (try (runVtyAppWithHandle defaultVtyAppConfig vty guest) :: IO (Either SomeException ()))
        case r of
          Nothing -> expectationFailure "host did not fail fast"
          Just (Left e) -> do
            show e `shouldContain` "app-boom"
            show e `shouldNotContain` "cleanup-boom"
          Just (Right ()) -> expectationFailure "expected the application failure"
        -- Later cleanup steps still ran despite the cursor/screen
        -- restore failing.
        logs <- recordingLogHistory st
        countIn "vty:shutdown" logs `shouldBe` 1

      it "attempts cursor and screen restoration on normal shutdown" $ do
        ready <- newEmptyMVar
        let guest :: VtyApp t m
            guest = triggerGuest ready (\_tick stop -> emptyPictureResult stop)
        (vty, st) <- recordingVty defaultRecordingVtyConfig
        hostA <- runHostAsync defaultVtyAppConfig vty guest
        (_, fireStop) <- takeMVar ready
        fireStop () (pure ())
        r <- timeout defaultDeadline (waitCatch hostA)
        case r of
          Nothing -> cancel hostA >> expectationFailure "host did not terminate"
          Just (Left e) -> expectationFailure ("host failed: " ++ show e)
          Just (Right ()) -> pure ()
        logs <- recordingLogHistory st
        -- The primary-screen restore emits escape sequence 1049l
        -- through the output byte buffer.
        any ("?1049l" `isInfixOf`) logs `shouldBe` True
        countIn "vty:shutdown" logs `shouldBe` 1

#if !defined(mingw32_HOST_OS)
      it "restores prior signal handlers on shutdown" $ do
        priorCount <- newIORef (0 :: Int)
        _ <- installHandler sigUSR1 (Catch (atomicModifyIORef' priorCount (\i -> (i + 1, ())))) Nothing
        ready <- newEmptyMVar
        -- The guest signals construction completion at post-build.
        -- The host installs signal handlers before post-build, so the
        -- handshake cannot race installation.
        let guest :: VtyApp t m
            guest _dr _inp _sigs = do
              pb <- getPostBuild
              performEvent_ $ ffor pb $ \_ -> liftIO (putMVar ready ())
              return (emptyPictureResult never)
        (vty, _) <- recordingVty defaultRecordingVtyConfig
        hostA <- runHostAsync defaultVtyAppConfig vty guest
        _ <- timeout defaultDeadline (takeMVar ready)
        cancel hostA
        _ <- waitCatch hostA
        -- Retrieve the currently active handler: it must be the prior
        -- counting handler again.
        active <- installHandler sigUSR1 (Catch (pure ())) Nothing
        case active of
          Catch act -> act
          _ -> expectationFailure "expected the prior handler to be restored"
        _ <- installHandler sigUSR1 active Nothing
        readIORef priorCount `shouldReturn` 1
#endif