diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,4 +8,27 @@
 
 ## Unreleased
 
-## 0.1.0.0 - YYYY-MM-DD
+## 0.1.0.3 — 2026-03-11
+
+### Fixed
+
+- Fixed test suite deadlock caused by a tight spin loop in the
+  `"primeCache into pending IVar while batch in flight"` race condition
+  test. The loop polled `cacheLookup` via `readIORef` without yielding,
+  starving the forked thread on ARM64 and blocking async exception
+  delivery. Added `yield` to break the spin.
+
+### Changed
+
+- Wrapped ~40 concurrency-prone tests with a `timed` combinator that
+  enforces a 5-second deadline, so future deadlocks fail fast instead
+  of hanging the suite.
+- Updated package metadata (author, maintainer, category, synopsis).
+
+## 0.1.0.2
+
+- Internal version bump (metadata only).
+
+## 0.1.0.0
+
+- Initial release.
diff --git a/sofetch.cabal b/sofetch.cabal
--- a/sofetch.cabal
+++ b/sofetch.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           sofetch
-version:        0.1.0.2
+version:        0.1.0.3
 synopsis:       A low-boilerplate Haxl-like data fetching library
 description:    Please see the README on GitHub at <https://github.com/githubuser/sofetch#readme>
 category:       Data
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -20,10 +20,11 @@
 import Fetch.IVar
 import Fetch.Cache
 
-import Control.Concurrent (forkIO)
+import Control.Concurrent (forkIO, yield)
 import Control.Concurrent.Async (async, wait, waitCatch, cancel, replicateConcurrently_)
 import Control.Concurrent.MVar
 import Control.Exception (SomeException, toException, try, throwTo)
+import System.Timeout (timeout)
 import qualified Control.Monad.Catch as MC
 import Data.HashMap.Strict (HashMap)
 import qualified Data.HashMap.Strict as HM
@@ -354,6 +355,20 @@
   lg <- readIORef logRef
   pure (a, lg)
 
+-- | Like 'it' but fails with a clear message if the test body doesn't
+-- complete within 5 seconds, preventing the suite from hanging forever.
+-- Uses async+cancel so the timeout exception is delivered even if the
+-- action is in a tight non-allocating loop.
+timed :: String -> IO () -> Spec
+timed name action = it name $ do
+  handle <- async action
+  result <- timeout 5000000 (wait handle)
+  case result of
+    Just () -> pure ()
+    Nothing -> do
+      cancel handle
+      expectationFailure "Test timed out (possible deadlock)"
+
 instance MC.MonadThrow TestM where
   throwM = testLiftIO . MC.throwM
 
@@ -489,7 +504,7 @@
       Right v -> v `shouldBe` 1
       Left _  -> expectationFailure "Expected Right (value should win)"
 
-  it "multiple concurrent readers all get same value" $ do
+  timed "multiple concurrent readers all get same value" $ do
     iv <- newIVar
     vars <- mapM (\_ -> do
       v <- newEmptyMVar
@@ -1119,7 +1134,7 @@
       dispLog `shouldContain` ["UserId"]
       dispLog `shouldContain` ["PostId"]
 
-    it "left immediate Left, right blocked → batch never executed (MVar proof)" $ do
+    timed "left immediate Left, right blocked → batch never executed (MVar proof)" $ do
       env <- mkTestEnv
       -- BlockingKey's batchFetch signals envAsyncStarted then blocks on envAsyncProceed.
       -- If biselect short-circuits, that batchFetch is never called.
@@ -1132,7 +1147,7 @@
       started <- tryTakeMVar (envAsyncStarted env)
       started `shouldBe` Nothing
 
-    it "right immediate Left, left blocked → batch never executed (MVar proof)" $ do
+    timed "right immediate Left, left blocked → batch never executed (MVar proof)" $ do
       env <- mkTestEnv
       result <- runTest env $
         biselect
@@ -1142,7 +1157,7 @@
       started <- tryTakeMVar (envAsyncStarted env)
       started `shouldBe` Nothing
 
-    it "both blocked, left resolves Left → right continuation abandoned (MVar proof)" $ do
+    timed "both blocked, left resolves Left → right continuation abandoned (MVar proof)" $ do
       env <- mkTestEnv
       -- Round 1: fetch UserId and PostId (both fast).
       -- After round 1: left produces Left, right would need BlockingKey (never reached).
@@ -1179,7 +1194,7 @@
       result <- runTest env $ pOr (pure False) (pure False)
       result `shouldBe` False
 
-    it "left pure True → right fetch never executed (MVar proof)" $ do
+    timed "left pure True → right fetch never executed (MVar proof)" $ do
       env <- mkTestEnv
       result <- runTest env $
         pOr (pure True) (const False <$> fetch (BlockingKey 1))
@@ -1187,7 +1202,7 @@
       started <- tryTakeMVar (envAsyncStarted env)
       started `shouldBe` Nothing
 
-    it "right pure True → left fetch never executed (MVar proof)" $ do
+    timed "right pure True → left fetch never executed (MVar proof)" $ do
       env <- mkTestEnv
       result <- runTest env $
         pOr (const False <$> fetch (BlockingKey 1)) (pure True)
@@ -1213,7 +1228,7 @@
       result `shouldBe` False
       length rounds `shouldBe` 1
 
-    it "multi-round: left True after round 1, right's round 2 abandoned (MVar proof)" $ do
+    timed "multi-round: left True after round 1, right's round 2 abandoned (MVar proof)" $ do
       env <- mkTestEnv
       (result, rounds) <- runTestWithRoundLog env $
         pOr
@@ -1250,7 +1265,7 @@
       result <- runTest env $ pAnd (pure False) (pure True)
       result `shouldBe` False
 
-    it "left pure False → right fetch never executed (MVar proof)" $ do
+    timed "left pure False → right fetch never executed (MVar proof)" $ do
       env <- mkTestEnv
       result <- runTest env $
         pAnd (pure False) (const True <$> fetch (BlockingKey 1))
@@ -1258,7 +1273,7 @@
       started <- tryTakeMVar (envAsyncStarted env)
       started `shouldBe` Nothing
 
-    it "right pure False → left fetch never executed (MVar proof)" $ do
+    timed "right pure False → left fetch never executed (MVar proof)" $ do
       env <- mkTestEnv
       result <- runTest env $
         pAnd (const True <$> fetch (BlockingKey 1)) (pure False)
@@ -1284,7 +1299,7 @@
       result `shouldBe` False
       length rounds `shouldBe` 1
 
-    it "multi-round: left False after round 1, right's round 2 abandoned (MVar proof)" $ do
+    timed "multi-round: left False after round 1, right's round 2 abandoned (MVar proof)" $ do
       env <- mkTestEnv
       (result, rounds) <- runTestWithRoundLog env $
         pAnd
@@ -1622,7 +1637,7 @@
 ivarRaceSpec :: Spec
 ivarRaceSpec = describe "IVar" $ do
 
-  it "concurrent write storm: exactly one winner across 100 threads" $ do
+  timed "concurrent write storm: exactly one winner across 100 threads" $ do
     iv <- newIVar
     doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. 100]
     barrier <- newEmptyMVar
@@ -1638,7 +1653,7 @@
       Right v -> v `shouldSatisfy` (\x -> x >= 1 && x <= 100)
       Left _  -> expectationFailure "Expected Right"
 
-  it "concurrent error + value writes: one winner, all readers agree" $ do
+  timed "concurrent error + value writes: one winner, all readers agree" $ do
     iv <- newIVar
     let n = 100
     doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]
@@ -1663,7 +1678,7 @@
           Left _  -> pure ()
           Right _ -> expectationFailure "Inconsistent: first was Left, got Right") rest
 
-  it "reader-writer interleave: N readers unblocked by single write" $ do
+  timed "reader-writer interleave: N readers unblocked by single write" $ do
     iv <- newIVar
     let numReaders = 50
     resultVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. numReaders]
@@ -1674,7 +1689,7 @@
       Right v -> v `shouldBe` 42
       Left _  -> expectationFailure "Expected Right") results
 
-  it "rapid alloc-write-read cycle stress (1000 iterations)" $ do
+  timed "rapid alloc-write-read cycle stress (1000 iterations)" $ do
     mapM_ (\i -> do
       iv <- newIVar
       writeIVar iv (i :: Int)
@@ -1691,7 +1706,7 @@
 cacheRaceSpec :: Spec
 cacheRaceSpec = describe "Cache" $ do
 
-  it "concurrent cacheAllocate same key: exactly one allocator wins" $ do
+  timed "concurrent cacheAllocate same key: exactly one allocator wins" $ do
     cRef <- newCacheRef
     resultsVar <- newIORef ([] :: [Int])
     barrier <- newEmptyMVar
@@ -1709,7 +1724,7 @@
     let allocators = filter (> 0) results
     length allocators `shouldBe` 1
 
-  it "cacheAllocate + cacheEvict interleave: no corruption" $ do
+  timed "cacheAllocate + cacheEvict interleave: no corruption" $ do
     cRef <- newCacheRef
     barrier <- newEmptyMVar
     h1 <- async $ do
@@ -1730,7 +1745,7 @@
       CacheHitReady v -> v `shouldBe` "value"
       CacheHitPending _ -> pure ()
 
-  it "cacheWarm + cacheLookup concurrent: never corrupt state" $ do
+  timed "cacheWarm + cacheLookup concurrent: never corrupt state" $ do
     cRef <- newCacheRef
     let warmMap = HM.fromList [ (UserId i, "user-" <> show i)
                                | i <- [1..100] ]
@@ -1754,7 +1769,7 @@
     bad <- readIORef badRef
     bad `shouldBe` False
 
-  it "cacheInsert after concurrent evict: no crash" $ do
+  timed "cacheInsert after concurrent evict: no crash" $ do
     cRef <- newCacheRef
     _ <- cacheAllocate @UserId cRef [UserId 1]
     barrier <- newEmptyMVar
@@ -1773,7 +1788,7 @@
       CacheHitReady _ -> pure ()
       CacheHitPending _ -> pure ()
 
-  it "concurrent cacheWarm different keys: both sets present" $ do
+  timed "concurrent cacheWarm different keys: both sets present" $ do
     cRef <- newCacheRef
     let set1 = HM.fromList [ (UserId i, "a-" <> show i) | i <- [1..50] ]
         set2 = HM.fromList [ (UserId i, "b-" <> show i) | i <- [51..100] ]
@@ -1784,7 +1799,7 @@
     contents <- cacheContents @UserId cRef Proxy
     mapM_ (\i -> HM.member (UserId i) contents `shouldBe` True) [1..100]
 
-  it "cacheContents during concurrent writes: internally consistent" $ do
+  timed "cacheContents during concurrent writes: internally consistent" $ do
     cRef <- newCacheRef
     barrier <- newEmptyMVar
     h1 <- async $ do
@@ -1810,7 +1825,7 @@
 engineRaceSpec :: Spec
 engineRaceSpec = describe "Engine dispatch" $ do
 
-  it "concurrent executeBatches on same CacheRef: no crash, all IVars filled" $ do
+  timed "concurrent executeBatches on same CacheRef: no crash, all IVars filled" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     let b1 = singletonBatch @TestM (UserId 1) <> singletonBatch @TestM (UserId 2)
@@ -1846,7 +1861,7 @@
     length results `shouldBe` 100
     results `shouldBe` ["range-" <> show i | i <- [1 :: Int .. 100]]
 
-  it "concurrent runFetchWithCache from multiple threads: no crash" $ do
+  timed "concurrent runFetchWithCache from multiple threads: no crash" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     barrier <- newEmptyMVar
@@ -1868,7 +1883,7 @@
 fetchTRaceSpec :: Spec
 fetchTRaceSpec = describe "Fetch / primeCache" $ do
 
-  it "concurrent primeCache + fetch for same key: no corruption" $ do
+  timed "concurrent primeCache + fetch for same key: no corruption" $ do
     mapM_ (\_ -> do
       env <- mkTestEnv
       cRef <- newCacheRef
@@ -1887,7 +1902,7 @@
       result `shouldSatisfy` (\v -> v == "primed" || v == "Alice")
       ) [1 :: Int .. 50]
 
-  it "primeCache into pending IVar while batch in flight" $ do
+  timed "primeCache into pending IVar while batch in flight" $ do
     env0 <- mkTestEnv
     slowBarrier <- newEmptyMVar
     let env' = env0 { envSlowBarrier = slowBarrier }
@@ -1900,14 +1915,14 @@
           hit <- cacheLookup cRef (SlowKey 1)
           case hit of
             CacheHitPending _ -> pure ()
-            _                 -> waitForPending
+            _                 -> yield >> waitForPending
     waitForPending
     runTestWithCache env' cRef $ primeCache (SlowKey 1) "primed-value"
     putMVar slowBarrier ()
     result <- takeMVar fetchDone
     result `shouldBe` "primed-value"
 
-  it "concurrent primeCache storm: one value wins" $ do
+  timed "concurrent primeCache storm: one value wins" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     barrier <- newEmptyMVar
@@ -1925,7 +1940,7 @@
     let hasPrimePrefix v = take 6 v == "prime-"
     result `shouldSatisfy` (\v -> hasPrimePrefix v || v == "Alice")
 
-  it "primeCache + cacheEvict race: no crash" $ do
+  timed "primeCache + cacheEvict race: no crash" $ do
     mapM_ (\_ -> do
       env <- mkTestEnv
       cRef <- newCacheRef
@@ -1953,7 +1968,7 @@
 memoRaceSpec :: Spec
 memoRaceSpec = describe "Memo" $ do
 
-  it "concurrent memo same key: action runs at most a few times" $ do
+  timed "concurrent memo same key: action runs at most a few times" $ do
     store <- newMemoStore
     callCount <- newIORef (0 :: Int)
     barrier <- newEmptyMVar
@@ -1972,7 +1987,7 @@
     count <- readIORef callCount
     count `shouldSatisfy` (< n)
 
-  it "concurrent memoOn same key: action runs at most a few times" $ do
+  timed "concurrent memoOn same key: action runs at most a few times" $ do
     store <- newMemoStore
     callCount <- newIORef (0 :: Int)
     barrier <- newEmptyMVar
@@ -1991,7 +2006,7 @@
     count <- readIORef callCount
     count `shouldSatisfy` (< n)
 
-  it "concurrent memo different keys: each runs exactly once" $ do
+  timed "concurrent memo different keys: each runs exactly once" $ do
     store <- newMemoStore
     callCount <- newIORef (0 :: Int)
     let n = 100
@@ -2986,7 +3001,7 @@
 asyncIVarSpec :: Spec
 asyncIVarSpec = describe "IVar" $ do
 
-  it "awaitIVar is interruptible by throwTo" $ do
+  timed "awaitIVar is interruptible by throwTo" $ do
     iv <- newIVar @Int
     started <- newEmptyMVar
     resultVar <- newEmptyMVar
@@ -3003,7 +3018,7 @@
       Left _  -> pure ()
       Right _ -> expectationFailure "Expected async exception from throwTo"
 
-  it "IVar writable after reader is killed; new reader sees value" $ do
+  timed "IVar writable after reader is killed; new reader sees value" $ do
     iv <- newIVar @Int
     started <- newEmptyMVar
     done <- newEmptyMVar
@@ -3022,7 +3037,7 @@
       Right v -> v `shouldBe` 42
       Left _  -> expectationFailure "Expected Right after writeIVar"
 
-  it "multiple readers: kill one, others still see value when written" $ do
+  timed "multiple readers: kill one, others still see value when written" $ do
     iv <- newIVar @Int
     started1 <- newEmptyMVar
     started2 <- newEmptyMVar
@@ -3049,7 +3064,7 @@
 asyncFetchSpec :: Spec
 asyncFetchSpec = describe "Fetch" $ do
 
-  it "cancel during batch execution propagates to caller" $ do
+  timed "cancel during batch execution propagates to caller" $ do
     env <- mkTestEnv
     handle <- async $ runTest env $ fetch (BlockingKey 1)
     -- Wait until the batch is in flight
@@ -3063,7 +3078,7 @@
     _ <- tryPutMVar (envAsyncProceed env) ()
     pure ()
 
-  it "completed results remain cached after later round is cancelled" $ do
+  timed "completed results remain cached after later round is cancelled" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     handle <- async $ runTestWithCache env cRef $ do
@@ -3079,7 +3094,7 @@
       CacheHitReady v -> v `shouldBe` "Alice"
       _               -> expectationFailure "Expected CacheHitReady for UserId 1"
 
-  it "concurrent threads sharing cache: cancel one, other completes" $ do
+  timed "concurrent threads sharing cache: cancel one, other completes" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     -- Thread A: fetches UserId 1, then blocks on BlockingKey
@@ -3096,7 +3111,7 @@
     _ <- tryPutMVar (envAsyncProceed env) ()
     pure ()
 
-  it "background batch thread fills IVars after parent is cancelled" $ do
+  timed "background batch thread fills IVars after parent is cancelled" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     handle <- async $ runTestWithCache env cRef $ do
@@ -3117,7 +3132,7 @@
       CacheHitReady v -> v `shouldBe` "blocking-1"
       CacheMiss -> expectationFailure "Expected cache entry for BlockingKey 1"
 
-  it "MonadCatch handler is NOT invoked for async exceptions during batch execution" $ do
+  timed "MonadCatch handler is NOT invoked for async exceptions during batch execution" $ do
     -- Fetch's catch wraps the probe phase, not batch execution.
     -- Async exceptions during executeBatches bypass MonadCatch.
     env <- mkTestEnv
@@ -3141,7 +3156,7 @@
     called <- readIORef handlerCalled
     called `shouldBe` False
 
-  it "throwTo with custom exception reaches caller via try" $ do
+  timed "throwTo with custom exception reaches caller via try" $ do
     env <- mkTestEnv
     started <- newEmptyMVar
     resultVar <- newEmptyMVar
@@ -3160,7 +3175,7 @@
     _ <- tryPutMVar (envAsyncProceed env) ()
     pure ()
 
-  it "cache not corrupted by cancelled computation; fresh run succeeds" $ do
+  timed "cache not corrupted by cancelled computation; fresh run succeeds" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     -- First run: fetch user, then block and get cancelled
@@ -3184,7 +3199,7 @@
 asyncMutateSpec :: Spec
 asyncMutateSpec = describe "Mutate" $ do
 
-  it "cancel before mutation: mutation never executes" $ do
+  timed "cancel before mutation: mutation never executes" $ do
     env <- mkTestEnv
     cRef <- newCacheRef
     handle <- async $ runMutateTestWithCache env cRef $ do
@@ -3201,7 +3216,7 @@
       CacheHitReady v -> v `shouldBe` "Alice"
       _               -> expectationFailure "Expected CacheHitReady for UserId 1"
 
-  it "cancel during fetch phase of Mutate propagates exception" $ do
+  timed "cancel during fetch phase of Mutate propagates exception" $ do
     env <- mkTestEnv
     handle <- async $ runMutateTest env $ do
       fetch (BlockingKey 1)  -- blocks, gets cancelled
