packages feed

atelier-core-0.1.0.0: test/Unit/Atelier/Effects/ConcSpec.hs

module Unit.Atelier.Effects.ConcSpec (spec_Conc) where

import Control.Exception (ErrorCall (..), throwIO)
import Effectful (IOE, runEff)
import Effectful.Concurrent (Concurrent, runConcurrent)
import Effectful.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVar, retry)
import Hedgehog (forAll, (===))
import Test.Hspec (Spec, anyException, context, describe, it, shouldBe, shouldSatisfy, shouldThrow)
import Test.Hspec.Hedgehog (hedgehog)

import Data.IORef qualified as IORef
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range

import Atelier.Effects.Conc (Conc, await, awaitAll, fork, forkTry, fork_, runConc, scoped)
import Atelier.Effects.Delay (Delay, runDelay)
import Atelier.Time (Millisecond)

import Atelier.Effects.Delay qualified as Delay


spec_Conc :: Spec
spec_Conc = do
    describe "Thread cleanup" do
        context "without scoped" do
            it "demonstrates thread lifetime" $ runTest do
                -- This test shows threads live for the duration of their scope
                counter <- newTVarIO (0 :: Int)

                -- Thread we fork here will live until the root scope (in
                -- 'runTest') exits.
                fork_ $ forever $ atomically $ modifyTVar' counter (+ 1)

                let waitFor n = atomically do
                        v <- readTVar counter
                        if v >= n then pure v else retry

                countAfter <- waitFor 1
                finalCount <- waitFor (countAfter + 1)

                liftIO $ finalCount `shouldSatisfy` (> countAfter)

        context "with scoped" do
            it "kills nested threads when scope exits" $ runTest do
                counter <- newTVarIO (0 :: Int)

                let waitFor n = atomically do
                        v <- readTVar counter
                        if v >= n then pure v else retry

                -- Create nested scope that will clean up its threads
                scoped do
                    fork_ $ forever $ atomically $ modifyTVar' counter (+ 1)
                    -- Ensure the thread has incremented at least once
                    _ <- waitFor 1
                    pure ()
                -- Inner scope exits here - thread should be KILLED

                -- Capture count after scope exit
                countAfter <- atomically $ readTVar counter

                -- Give any leaked thread a brief window to advance the counter
                Delay.wait (1 :: Millisecond)

                -- Count should NOT increase after scope exit
                finalCount <- atomically $ readTVar counter

                liftIO $ finalCount `shouldBe` countAfter

    describe "fork and await" do
        it "returns the result of the forked computation" do
            result <- runTestSimple $ do
                t <- fork $ pure (42 :: Int)
                await t
            result `shouldBe` 42

        it "executes the forked action" do
            result <- runTestSimple $ do
                ref <- liftIO $ IORef.newIORef False
                t <- fork $ liftIO $ IORef.writeIORef ref True
                await t
                liftIO $ IORef.readIORef ref
            result `shouldBe` True

    describe "awaitAll" do
        it "waits for all forked threads to complete" $ hedgehog do
            n <- forAll $ Gen.int (Range.linear 1 20)
            result <- liftIO $ runTestSimple $ do
                ref <- liftIO $ IORef.newIORef (0 :: Int)
                replicateM_ n $ fork $ liftIO $ IORef.atomicModifyIORef' ref (\x -> (x + 1, ()))
                awaitAll
                liftIO $ IORef.readIORef ref
            result === n

    describe "forkTry" do
        it "returns Right for a successful computation" do
            result <- runTestSimple $ do
                t <- forkTry @ErrorCall $ pure (42 :: Int)
                await t
            result `shouldBe` Right 42

        it "returns Left when the forked thread throws" do
            result <- runTestSimple $ do
                t <- forkTry @ErrorCall $ liftIO $ throwIO $ ErrorCall "boom"
                await t
            (result :: Either ErrorCall Int) `shouldSatisfy` isLeft

    describe "exception propagation" do
        it "uncaught exception in forked thread propagates to the scope" do
            let action = runTestSimple $ do
                    _ <- fork $ liftIO $ throwIO $ ErrorCall "boom"
                    awaitAll
            action `shouldThrow` anyException


--------------------------------------------------------------------------------
-- Test Helpers
--------------------------------------------------------------------------------

runTest :: Eff '[Delay, Conc, Concurrent, IOE] a -> IO a
runTest = runEff . runConcurrent . runConc . runDelay


runTestSimple :: Eff '[Conc, Concurrent, IOE] a -> IO a
runTestSimple = runEff . runConcurrent . runConc