kiroku-store-0.13.0.1: test/Test/AsyncCancellation.hs
module Test.AsyncCancellation (spec, publisherSpec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar, tryReadMVar)
import Control.Concurrent.STM (atomically)
import Control.Exception (AsyncException (ThreadKilled, UserInterrupt), Exception (..), SomeException, asyncExceptionFromException, asyncExceptionToException, bracket, finally, throwIO, throwTo, try)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (forM_, void)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.Text (Text)
import Kiroku.Store
import Kiroku.Store.Notification qualified as N
import Kiroku.Store.Observability (emitOrDrop)
import Kiroku.Store.Subscription.EventPublisher qualified as P
import Kiroku.Test.Postgres (withMigratedTestDatabase)
import System.Timeout (timeout)
import Test.Helpers (listenerCount, makeEvent, withTestStoreSettings)
import Test.Hspec
-- The payload ensures rethrowing preserves a consumer-defined exception too.
data PayloadCancellation = PayloadCancellation Int deriving stock (Eq, Show)
instance Exception PayloadCancellation where
toException = asyncExceptionToException
fromException = asyncExceptionFromException
data SyncFailure = SyncFailure deriving stock (Eq, Show)
instance Exception SyncFailure
cases :: [(String, SomeException, SomeException -> Bool)]
cases =
[ ("AsyncCancelled", toException Async.AsyncCancelled, matches Async.AsyncCancelled),
("ThreadKilled", toException ThreadKilled, matches ThreadKilled),
("UserInterrupt", toException UserInterrupt, matches UserInterrupt),
("custom payload", toException (PayloadCancellation 73), matches (PayloadCancellation 73))
]
where
matches :: (Exception e, Eq e) => e -> SomeException -> Bool
matches expected e = fromException e == Just expected
bounded :: IO a -> IO a
bounded action = timeout 10_000_000 action >>= maybe (fail "asynchronous boundary timed out") pure
expectException :: (SomeException -> Bool) -> Either SomeException a -> Expectation
expectException matches = \case
Left e -> e `shouldSatisfy` matches
Right _ -> expectationFailure "expected asynchronous exception; action returned normally"
emit :: IO () -> IO ()
emit action = emitOrDrop (Just (const action)) KirokuEventNotifierReconnected
-- Release the blocked action before the scoped async finalizer on every exit.
-- This also makes the original, cancellation-swallowing implementation safe to test.
crossThread :: (IO () -> IO ()) -> (Async.Async () -> IO ()) -> (SomeException -> Bool) -> IO ()
crossThread boundary send matches = do
entered <- newEmptyMVar
release <- newEmptyMVar
Async.withAsync (boundary (putMVar entered () >> takeMVar release)) $ \target ->
( do
bounded (takeMVar entered)
bounded (send target)
bounded (Async.waitCatch target) >>= expectException matches
)
`finally` void (tryPutMVar release ())
awaitNoListeners :: Text -> Int64 -> IO ()
awaitNoListeners conn baseline = bounded loop
where
loop = do
n <- listenerCount conn
if n == baseline then pure () else threadDelay 1_000 >> loop
spec :: Spec
spec = describe "asynchronous cancellation" $ do
it "allows absent and successful observation callbacks" $ do
emitOrDrop Nothing KirokuEventNotifierReconnected
called <- newIORef False
emit (writeIORef called True)
readIORef called `shouldReturn` True
it "suppresses synchronous observation failures" $ emit (throwIO SyncFailure)
forM_ cases $ \(label, exception, matches) -> do
it ("preserves observation " <> label) $
try (emit (throwIO exception)) >>= expectException matches
it ("preserves notifier startup " <> label <> " and releases its connection") $
withMigratedTestDatabase $ \conn -> do
baseline <- listenerCount conn
baseline `shouldBe` 0
N.withListenHookForTest
( \_ -> do
listenerCount conn `shouldReturn` (baseline + 1)
throwIO exception
)
$ do
result <- try (N.startNotifier conn "kiroku" Nothing)
-- Even an incorrect successful startup must be cleaned up.
either (const (pure ())) N.stopNotifier result
expectException matches result
awaitNoListeners conn baseline
it "wraps synchronous notifier startup failure with its original cause and releases the connection" $
withMigratedTestDatabase $ \conn -> do
baseline <- listenerCount conn
N.withListenHookForTest (\_ -> throwIO SyncFailure) $ do
result <- try (N.startNotifier conn "kiroku" Nothing)
either (const (pure ())) N.stopNotifier result
case result of
Left e
| Just (N.NotifierListenError cause) <- fromException e ->
fromException cause `shouldBe` Just SyncFailure
_ -> expectationFailure "expected NotifierListenError with synchronous cause"
awaitNoListeners conn baseline
forM_ [False, True] $ \custom -> do
let send target = if custom then throwTo (Async.asyncThreadId target) (PayloadCancellation 73) else Async.cancel target
matches e = if custom then fromException e == Just (PayloadCancellation 73) else fromException e == Just Async.AsyncCancelled
it ("terminates a blocked observation callback, custom=" <> show custom) $
crossThread emit send matches
it ("terminates blocked notifier startup and releases its connection, custom=" <> show custom) $
withMigratedTestDatabase $ \conn -> do
baseline <- listenerCount conn
let boundary action =
N.withListenHookForTest (\_ -> (listenerCount conn `shouldReturn` (baseline + 1)) >> action) $
bracket (N.startNotifier conn "kiroku" Nothing) N.stopNotifier (const (pure ()))
crossThread boundary send matches
awaitNoListeners conn baseline
publisherSpec :: Spec
publisherSpec = describe "asynchronous publisher recovery" $ do
forM_ [False, True] $ \callback -> do
forM_ cases $ \(label, exception, matches) ->
it ("preserves " <> label <> ", callback=" <> show callback) $
publisherCase callback (throwIO exception) Nothing (pure ()) matches
forM_ [False, True] $ \custom ->
it ("terminates blocked action, callback=" <> show callback <> ", custom=" <> show custom) $ do
entered <- newEmptyMVar
release <- newEmptyMVar
let action = putMVar entered () >> takeMVar release
send target = do
bounded (takeMVar entered)
bounded (if custom then throwTo (Async.asyncThreadId target) (PayloadCancellation 73) else Async.cancel target)
matches e = if custom then fromException e == Just (PayloadCancellation 73) else fromException e == Just Async.AsyncCancelled
publisherCase callback action (Just send) (void (tryPutMVar release ())) matches
publisherCase :: Bool -> IO () -> Maybe (Async.Async () -> IO ()) -> IO () -> (SomeException -> Bool) -> IO ()
publisherCase callback action sender cleanup matches = do
loopError <- newEmptyMVar
let hook event =
if callback
then pure (Left (DecodeFailure (event ^. #eventId) "injected decode failure"))
else action >> pure (Right event)
observe event = case event of
KirokuEventPublisherDecodeFailed {} | callback -> action
KirokuEventPublisherLoopError {} -> void (tryPutMVar loopError ())
_ -> pure ()
tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook} & #eventHandler .~ Just observe
withTestStoreSettings tweak $ \store -> do
let pub = store ^. #publisher
target = P.publisherThread pub
( bracket (atomically (P.subscribePublisher pub 1 PauseAndResume)) (\(_, _, unsubscribe) -> unsubscribe) $ \_ -> do
Right _ <- runStoreIO store $ appendToStream (StreamName "async-boundary") NoStream [makeEvent "Cancel" (Aeson.object [])]
forM_ sender ($ target)
outcome <- bounded $ Async.race (takeMVar loopError) (Async.waitCatch target)
case outcome of
Left () -> expectationFailure "asynchronous exception became a publisher loop error"
Right result -> expectException matches result
tryReadMVar loopError `shouldReturn` Nothing
atomically (P.publisherPosition pub) `shouldReturn` GlobalPosition 0
)
`finally` cleanup