atelier-core-0.1.0.0: test/Unit/Atelier/Effects/Conc/TeardownStressSpec.hs
-- | Focused stress tests for 'Conc.scoped' teardown.
--
-- The full test suite occasionally deadlocks under heavy scheduler pressure
-- (CPU oversubscription). Earlier A/B experiments ruled out the channel
-- primitive: converting the Iterator/Publishing path from unagi to STM did not
-- change the hang rate, which points at the one layer common to every victim —
-- Ki structured-concurrency teardown in the 'Conc' effect ('runConc' /
-- 'Conc.scoped' killing and awaiting forked threads at scope close).
--
-- These tests hammer that teardown in a tight loop so a low-probability
-- lost-wakeup becomes near-certain under load, isolating it from Pub/Sub,
-- Iterator, channels, and everything else. Iteration count is tunable via
-- @ATELIER_CONC_STRESS_N@ (crank it up under @yes@-load to reproduce). Each
-- test is wrapped in a 'timeout' so a real teardown hang fails loudly instead
-- of wedging the run.
module Unit.Atelier.Effects.Conc.TeardownStressSpec (spec_ConcTeardownStress) where
import Control.Concurrent (threadDelay)
import Control.Exception (evaluate)
import Effectful (IOE, runEff)
import Effectful.Concurrent (Concurrent, runConcurrent)
import Effectful.Concurrent.STM (atomically, retry)
import System.Environment (lookupEnv)
import System.Timeout (timeout)
import Test.Hspec (Spec, describe, it, runIO, shouldBe)
import Atelier.Effects.Chan (Chan, runChan)
import Atelier.Effects.Clock (Clock, runClock)
import Atelier.Effects.Conc (Conc, fork, fork_, runConc, scoped)
import Atelier.Effects.Monitoring.Tracing (Tracing, runTracingNoOp)
import Atelier.Effects.Publishing (Pub, Sub, publish, runPubSub)
import Atelier.Effects.Iterator qualified as Iter
spec_ConcTeardownStress :: Spec
spec_ConcTeardownStress = do
iterations <- runIO (fromMaybe defaultIterations . (>>= readMaybe) <$> lookupEnv "ATELIER_CONC_STRESS_N")
timeoutSecs <- runIO (fromMaybe defaultTimeoutSecs . (>>= readMaybe) <$> lookupEnv "ATELIER_CONC_STRESS_TIMEOUT_S")
runSpin <- runIO (isJust <$> lookupEnv "ATELIER_CONC_SPIN")
publishDelayUs <- runIO (fromMaybe defaultPublishDelayUs . (>>= readMaybe) <$> lookupEnv "ATELIER_CONC_STRESS_DELAY_US")
let testTimeoutMicros = timeoutSecs * 1_000_000
describe "Conc.scoped teardown stress" do
it "reaps an STM-blocked fork_ across many scopes" do
completed <-
timeout testTimeoutMicros
$ runConcTest
$ replicateM_ iterations
$ scoped (void (fork_ blockForever))
completed `shouldBe` Just ()
it "reaps a two-child scope (one parked, one transient) across many scopes" do
completed <-
timeout testTimeoutMicros
$ runConcTest
$ replicateM_ iterations
$ scoped do
_ <- fork (liftIO (threadDelay 5))
void (fork_ blockForever)
completed `shouldBe` Just ()
-- Faithful repro of the actual full-suite victim: the 'fromEvents' Iterator
-- pattern over its real effect stack, looped. The producer's 10us delay is
-- the ONLY thing giving the forked listener time to subscribe before the
-- first publish; under load that race can drop early events and wedge the
-- consumer's 'Iter.next' (it reads exactly as many as were published, so any
-- dropped event blocks forever). Also exercises the deep-stack
-- 'Conc.scoped' unlift/teardown that the bare tests above strip away.
describe "fromEvents Iterator pattern stress (full stack)" do
it "drives the producer/listener fromEvents scope across many iterations" do
completed <-
timeout testTimeoutMicros
$ runIterTest
$ replicateM_ iterations
$ Iter.fromEvents @Int \iter -> do
_ <- fork do
liftIO (threadDelay publishDelayUs)
traverse_ publish [1, 2, 3 :: Int]
_ <- replicateM 3 (Iter.next iter)
pure ()
completed `shouldBe` Just ()
-- A non-allocating fork_ is UN-KILLABLE under -O: -fomit-yields strips the
-- loop's safe point, so the async exception Ki throws at scope close — and
-- the one 'timeout' would throw — can never be delivered. It would wedge an
-- optimized build permanently and burn a core. Hence opt-in, and only safe
-- on a -O0 build (where the boxed-Int loop still allocates and stays
-- killable). This is the discriminating probe for the omit-yields theory.
when runSpin
$ describe "Conc.scoped teardown of a NON-ALLOCATING fork_ (ATELIER_CONC_SPIN=1; -O0 only)" do
it "reaps a non-allocating spin at scope exit" do
completed <-
timeout testTimeoutMicros
$ runConcTest
$ scoped (void (fork_ nonAllocatingSpin))
completed `shouldBe` Just ()
where
defaultIterations = 300 :: Int
defaultTimeoutSecs = 15 :: Int
defaultPublishDelayUs = 10 :: Int
blockForever :: (Concurrent :> es) => Eff es Void
blockForever = atomically retry
-- Boxed-Int loop: allocates (killable) at -O0, worker-wraps to a
-- non-allocating Int# loop (un-killable) at -O.
nonAllocatingSpin :: (IOE :> es) => Eff es Void
nonAllocatingSpin = liftIO (evaluate (spin 0))
where
spin :: Int -> Void
spin !n = spin (n + 1)
runConcTest :: Eff '[Conc, Concurrent, IOE] a -> IO a
runConcTest = runEff . runConcurrent . runConc
-- | Mirrors 'IteratorSpec.runTest' — the full effect stack the real victim runs on.
runIterTest :: Eff '[Pub Int, Sub Int, Chan, Clock, Tracing, Conc, Concurrent, IOE] a -> IO a
runIterTest = runEff . runConcurrent . runConc . runTracingNoOp . runClock . runChan . runPubSub @Int