packages feed

glue-common-0.8.0.0: src/Glue/Clock/Virtual.hs

{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | A virtual t'Clock' for deterministic tests: time only moves when 'advanceClock' is called,
-- and sleepers are woken in deadline order as it passes them.
--
-- A typical spec starts the code under test, waits for it to block with 'waitForSleepers',
-- then steps time with 'advanceClock' and asserts on what happened.
module Glue.Clock.Virtual
  ( VirtualClock
  , newVirtualClock
  , virtualClock
  , advanceClock
  , virtualNow
  , sleeperCount
  , waitForSleepers
  ) where

import           Control.Monad  (when)
import           Data.Int       (Int64)
import qualified Data.Set       as S
import           Glue.Clock
import           UnliftIO       (MonadIO, MonadUnliftIO)
import           UnliftIO.Async (race)
import           UnliftIO.Exception (onException)
import           UnliftIO.STM   (TVar, atomically, modifyTVar', newTVarIO,
                                 readTVar, retrySTM, stateTVar, writeTVar)

-- | Virtual time, starting at zero, plus the sleepers waiting on it.
data VirtualClock = VirtualClock
  { vcNow      :: TVar Int64
  , vcSleepers :: TVar (S.Set (Int64, Int))
  -- ^ Each sleeper as (deadline, unique id).
  , vcNextId   :: TVar Int
  }

-- | Creates a virtual clock at time zero with no sleepers.
newVirtualClock :: MonadIO m => m VirtualClock
newVirtualClock = VirtualClock <$> newTVarIO 0 <*> newTVarIO S.empty <*> newTVarIO 0

-- | A t'Clock' reading from the virtual clock.
virtualClock :: MonadUnliftIO m => VirtualClock -> Clock m
virtualClock vc =
  Clock
    { clockNow     = virtualNow vc
    , clockSleep   = virtualSleep vc
    , clockTimeout = virtualTimeout vc
    }

-- | The current virtual time in microseconds.
virtualNow :: MonadIO m => VirtualClock -> m Int64
virtualNow vc = atomically $ readTVar (vcNow vc)

-- | The number of threads currently sleeping on the clock.
sleeperCount :: MonadIO m => VirtualClock -> m Int
sleeperCount vc = atomically $ S.size <$> readTVar (vcSleepers vc)

-- | Blocks until at least the given number of threads are sleeping on the clock.
waitForSleepers :: MonadIO m => VirtualClock -> Int -> m ()
waitForSleepers vc n = atomically $ do
  sleepers <- readTVar (vcSleepers vc)
  when (S.size sleepers < n) retrySTM

virtualSleep :: MonadUnliftIO m => VirtualClock -> Int -> m ()
virtualSleep vc us
  | us <= 0   = pure ()
  | otherwise = do
      sleeper <- atomically $ do
        now <- readTVar (vcNow vc)
        uid <- stateTVar (vcNextId vc) (\i -> (i, i + 1))
        let entry = (now + fromIntegral us, uid)
        modifyTVar' (vcSleepers vc) (S.insert entry)
        pure entry
      let remove = atomically $ modifyTVar' (vcSleepers vc) (S.delete sleeper)
      -- Waking and deregistering happen in one transaction, which 'advanceClock' relies on.
      atomically (do
          now <- readTVar (vcNow vc)
          when (now < fst sleeper) retrySTM
          modifyTVar' (vcSleepers vc) (S.delete sleeper))
        `onException` remove

virtualTimeout :: MonadUnliftIO m => VirtualClock -> Int -> m b -> m (Maybe b)
virtualTimeout vc us action
  | us < 0    = Just <$> action
  | us == 0   = pure Nothing
  | otherwise = either (const Nothing) Just <$> race (virtualSleep vc us) action

-- | Moves time forward by the given number of microseconds. Time steps to each sleeper's
-- deadline in order, waiting at each step until every sleeper due by then has woken.
advanceClock :: MonadIO m => VirtualClock -> Int -> m ()
advanceClock vc us = do
  target <- atomically $ (+ fromIntegral us) <$> readTVar (vcNow vc)
  let step = do
        stepped <- atomically $ do
          sleepers <- readTVar (vcSleepers vc)
          case S.lookupMin sleepers of
            Just (deadline, _) | deadline <= target -> do
              now <- readTVar (vcNow vc)
              writeTVar (vcNow vc) (max now deadline)
              pure True
            _ -> do
              writeTVar (vcNow vc) target
              pure False
        when stepped $ do
          atomically $ do
            now      <- readTVar (vcNow vc)
            sleepers <- readTVar (vcSleepers vc)
            case S.lookupMin sleepers of
              Just (deadline, _) | deadline <= now -> retrySTM
              _                                    -> pure ()
          step
  step