packages feed

tricorder-0.2.0.0: test/Unit/Tricorder/WaitersSpec.hs

module Unit.Tricorder.WaitersSpec (spec_Waiters) where

import Atelier.Effects.Conc (Conc, runConc)
import Effectful (IOE, runEff)
import Effectful.Concurrent (Concurrent, runConcurrent)
import Effectful.Exception (catch, throwIO)
import Effectful.State.Static.Shared (State, get, modify, runState)
import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList, shouldThrow)

import Atelier.Effects.Conc qualified as Conc
import Atelier.Types.Semaphore qualified as Sem

import Tricorder.Waiters (Waiters)

import Tricorder.Waiters qualified as Waiters


-- | Test exception type, used to prove that a waiter's slot is released
-- even when its action throws.
data TestException = TestException Text
    deriving stock (Eq, Show)
    deriving anyclass (Exception)


runWaitersTest
    :: Eff [Waiters, State [Text], Conc, Concurrent, IOE] a
    -> IO (a, [Text])
runWaitersTest =
    runEff
        . runConcurrent
        . runConc
        . runState @[Text] []
        . Waiters.run


logEvent :: (State [Text] :> es) => Text -> Eff es ()
logEvent name = modify (<> [name])


spec_Waiters :: Spec
spec_Waiters = do
    describe "Waiters.without" do
        describe "with waiters" $ it "skips the action" do
            (_, events) <- runWaitersTest do
                proceed <- Sem.new
                started <- Sem.new
                waiter <- Conc.fork $ Waiters.with do
                    void $ Sem.set started
                    Sem.wait proceed
                    logEvent "waiter"
                Sem.wait started
                -- Called directly (not forked): Without never blocks, so
                -- forking it here would race its no-waiters check against
                -- the `Sem.set proceed` below, making the test flaky.
                Waiters.without (logEvent "without")
                void $ Sem.set proceed
                Conc.await waiter
            events `shouldBe` ["waiter"]
        it "runs its action immediately when there are no active waiters" do
            (_, events) <- runWaitersTest $ Waiters.without (logEvent "ran")
            events `shouldBe` ["ran"]

    describe "Waiters.with" do
        it "runs its action" do
            (_, events) <- runWaitersTest $ Waiters.with (logEvent "ran")
            events `shouldBe` ["ran"]

        it "lets a subsequent Waiters.wait proceed once it has completed" do
            (_, events) <- runWaitersTest do
                Waiters.with (logEvent "waiter")
                Waiters.wait (logEvent "quiesced")
            events `shouldBe` ["waiter", "quiesced"]

        it "blocks a concurrent Waiters.wait until its action finishes" do
            (_, events) <- runWaitersTest do
                started <- Sem.new
                proceed <- Sem.new
                waiter <- Conc.fork $ Waiters.with do
                    _ <- Sem.set started
                    Sem.wait proceed
                    logEvent "waiter-end"
                Sem.wait started
                quiescent <- Conc.fork $ Waiters.wait (logEvent "quiesced")
                _ <- Sem.set proceed
                Conc.await waiter
                Conc.await quiescent
            -- Blocking is guaranteed by the STM retry on the waiter count,
            -- not by scheduling luck, so this order holds on every run.
            events `shouldBe` ["waiter-end", "quiesced"]

        it "blocks wait until every concurrent waiter finishes" do
            (afterFirst, events) <- runWaitersTest do
                started1 <- Sem.new
                proceed1 <- Sem.new
                waiter1 <- Conc.fork $ Waiters.with do
                    _ <- Sem.set started1
                    Sem.wait proceed1
                    logEvent "waiter1-end"
                Sem.wait started1

                quiescent <- Conc.fork $ Waiters.wait (logEvent "quiesced")

                started2 <- Sem.new
                proceed2 <- Sem.new
                waiter2 <- Conc.fork $ Waiters.with do
                    _ <- Sem.set started2
                    Sem.wait proceed2
                    logEvent "waiter2-end"
                Sem.wait started2

                _ <- Sem.set proceed1
                Conc.await waiter1
                -- waiter2 is still active here, so the waiter count cannot
                -- have reached zero yet: quiescent is still blocked, not
                -- just "hasn't been scheduled".
                afterFirst <- get
                _ <- Sem.set proceed2
                Conc.await waiter2
                -- No snapshot is taken here: once waiter2's slot is released,
                -- quiescent's STM retry can wake and log concurrently with
                -- this thread, so there is no deterministic in-between state
                -- to observe. Only the final, fully-awaited state is safe to
                -- assert on.
                Conc.await quiescent
                pure afterFirst
            afterFirst `shouldMatchList` ["waiter1-end"]
            events `shouldMatchList` ["waiter1-end", "waiter2-end", "quiesced"]

    describe "exception safety" do
        it "propagates an exception raised by the waiter's action" do
            let action = runWaitersTest $ Waiters.with (throwIO $ TestException "boom")
            action `shouldThrow` \(TestException msg) -> msg == "boom"

        it "releases the waiter slot even when the action throws" do
            (_, events) <- runWaitersTest do
                _ <-
                    Waiters.with (throwIO $ TestException "boom")
                        `catch` \(_ :: TestException) -> pure ()
                Waiters.without (logEvent "quiesced")
            events `shouldBe` ["quiesced"]