packages feed

bluefin-internal-0.10.1.0: test/Test/RunPureEff.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE NoMonoLocalBinds #-}
{-# LANGUAGE NoMonomorphismRestriction #-}

module Test.RunPureEff where

import Bluefin.Internal
import Control.Concurrent (threadDelay, throwTo)
import Control.Concurrent.Async (asyncThreadId, waitCatch, withAsync)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar)
import Control.Exception (AsyncException (ThreadKilled), SomeException, evaluate)
import Control.Exception qualified as Exception
import Control.Monad (forever, unless)
import Data.IORef (atomicModifyIORef', newIORef, readIORef, writeIORef)
import System.Mem (performMajorGC)
import System.Timeout (timeout)
import Test.SpecH (SpecH, assertSatisfies)

-- Interrupt a thread while it forces a shared thunk which runs inside
-- a bracket, then force the thunk again and check from what point the
-- computation resumed.
test_runPureEffAsyncSafeSurvivesInterruptedBracket ::
  (forall r. (forall e. Eff e r) -> r) ->
  IO InterruptedBracketResult
test_runPureEffAsyncSafeSurvivesInterruptedBracket run = do
  started <- newEmptyMVar
  continue <- newEmptyMVar
  forceThunk <- do
    modified <- newIORef False
    ref <- newIORef $ run $ bracket (pure ()) (\() -> pure ()) $ \() -> do
      let prog = do
            previouslyModified <-
              atomicModifyIORef' modified $ \previous ->
                (True, previous)
            -- Unless the IORef was previously modified, this is our
            -- first run. Therefore, wait for the caller to unblock us,
            -- and throw us an asynchronous exception.
            unless previouslyModified $ do
              _ <- tryPutMVar started ()
              takeMVar continue
            pure previouslyModified
      unsafeProvideIO (\io -> effIO io prog)
    pure (evaluate =<< readIORef ref)
  interrupted <- withAsync forceThunk $ \worker -> do
    takeMVar started
    throwTo (asyncThreadId worker) ThreadKilled
    waitCatch worker
  case interrupted of
    Left eInterrupted
      | Just ThreadKilled <- Exception.fromException eInterrupted -> do
          resumed <- do
            -- If the first evaluation is resumed, it will need
            -- unblocking.  If evaluation started from scratch it
            -- won't block on continue anyway, because
            -- previouslyModified is true.
            putMVar continue ()
            Exception.try @SomeException $ do
              forceThunk
          case resumed of
            Left eResumed
              | Just ThreadKilled <- Exception.fromException eResumed ->
                  pure ThunkPoisoned
              | otherwise ->
                  pure (UnexpectedException eResumed)
            Right previouslyModified ->
              pure $ case previouslyModified of
                False -> ThunkClean
                True -> RanAtLeastTwice
      | otherwise -> pure (UnexpectedException eInterrupted)
    Right _ -> pure FinishedEarly

-- Drop the result thunk after cancelling its forcing thread, then collect it
-- and check that its weak finalizer kills the computation worker.
test_runPureEffAsyncSafeReapsWorker :: IO (Maybe AsyncException)
test_runPureEffAsyncSafeReapsWorker = do
  started <- newEmptyMVar
  caught <- newEmptyMVar
  (release, forceThunk) <- do
    shared <- newIORef @(Maybe ()) $ Just $ runPureEffAsyncSafe $ do
      unsafeProvideIO $ \io -> do
        effIO io $ do
          putMVar started ()
          Exception.handle @SomeException
            (putMVar caught)
            (forever (threadDelay 1_000_000))
    pure
      ( writeIORef shared Nothing,
        maybe (fail "result thunk released") evaluate =<< readIORef shared
      )
  withAsync forceThunk $ \forcingThunk -> do
    takeMVar started
    throwTo (asyncThreadId forcingThunk) ThreadKilled
    waitCatch forcingThunk >>= \case
      Right () ->
        fail "forcing thunk completed instead of being killed"
      Left e
        | -- We expect forcingThurk to be killed by ThreadKilled,
          -- because that's what we just threw to it.
          Just ThreadKilled <- Exception.fromException e ->
            pure ()
        | -- If we were killed by anything else, that should be
          -- reported
          otherwise ->
            Exception.throwIO e

  release
  performMajorGC
  caughtException <- timeout 1000000 (takeMVar caught)
  pure (Exception.fromException =<< caughtException)

data InterruptedBracketResult
  = FinishedEarly
  | UnexpectedException !SomeException
  | ThunkPoisoned
  | ThunkClean
  | RanAtLeastTwice
  deriving stock (Show)

isClean :: InterruptedBracketResult -> Bool
isClean = \case
  ThunkClean -> True
  _ -> False

isPoisoned :: InterruptedBracketResult -> Bool
isPoisoned = \case
  ThunkPoisoned -> True
  _ -> False

isRanAtLeastTwice :: InterruptedBracketResult -> Bool
isRanAtLeastTwice = \case
  RanAtLeastTwice -> True
  _ -> False

assertInterruptedBracketOutcome ::
  (e1 <: es, e2 <: es) =>
  IOE e1 ->
  SpecH e2 ->
  String ->
  (InterruptedBracketResult -> Bool) ->
  (forall r. (forall e. Eff e r) -> r) ->
  Eff es ()
assertInterruptedBracketOutcome io y name predicate run = do
  actual <- effIO io $ test_runPureEffAsyncSafeSurvivesInterruptedBracket run
  assertSatisfies y name predicate actual