packages feed

shibuya-core 0.9.0.1 → 0.9.0.2

raw patch · 5 files changed

+122/−93 lines, 5 filesdep ~effectfuldep ~streamly-coredep ~unliftioPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependency ranges changed: effectful, streamly-core, unliftio

API changes (from Hackage documentation)

- Shibuya.Internal.Runner.Master: RegisterProcessor :: !ProcessorId -> !MetricsHandle -> !Listen () -> MasterMessage
- Shibuya.Internal.Runner.Master: Shutdown :: !Listen () -> MasterMessage
- Shibuya.Internal.Runner.Master: UnregisterProcessor :: !ProcessorId -> !Listen () -> MasterMessage
- Shibuya.Internal.Runner.Master: [handle] :: Master -> !Async ()
- Shibuya.Internal.Runner.Master: [inbox] :: Master -> !Inbox MasterMessage
- Shibuya.Internal.Runner.Master: data Master
- Shibuya.Internal.Runner.Master: data MasterMessage
+ Shibuya.Internal.Runner.Master: newtype Master
- Shibuya.Internal.Runner.Master: Master :: !Async () -> !MasterState -> !Inbox MasterMessage -> Master
+ Shibuya.Internal.Runner.Master: Master :: MasterState -> Master
- Shibuya.Internal.Runner.Master: [state] :: Master -> !MasterState
+ Shibuya.Internal.Runner.Master: [state] :: Master -> MasterState

Files

CHANGELOG.md view
@@ -1,5 +1,21 @@ # Changelog +## 0.9.0.2 — 2026-09-19++### Bug Fixes++- Remove the unused linked master mailbox actor. Its inbox had no senders, so+  a caller that started an idle application and retained only `waitApp` could+  receive `ExceptionInLinkedThread ... thread blocked indefinitely in an STM+  transaction` during major garbage collection. `Master` now owns only the+  NQE supervisor and metrics registry; processor supervision, failure+  propagation, metrics access, and explicit shutdown behavior are unchanged.++### Other Changes++- Add a process-isolated garbage-collection regression that exercises bare+  `runApp`/`waitApp` liveness under the normal optimized test profile.+ ## 0.9.0.1 — 2026-09-15  ### Other Changes
shibuya-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.12 name: shibuya-core-version: 0.9.0.1+version: 0.9.0.2 synopsis: Supervised queue processing framework for Haskell description:   A supervised queue processing framework inspired by Broadway (Elixir).@@ -150,3 +150,23 @@     time,     unliftio,     unordered-containers,++-- Isolate the bare-waitApp liveness probe so process exit cleans up without+-- retaining the master (which would conceal the regression).+test-suite shibuya-core-gc-test+  import: warnings+  default-language: GHC2024+  type: exitcode-stdio-1.0+  hs-source-dirs: test-gc+  main-is: Main.hs+  ghc-options:+    -threaded+    -rtsopts+    -with-rtsopts=-N2++  build-depends:+    base ^>=4.21.0.0,+    effectful,+    shibuya-core,+    streamly-core,+    unliftio,
src/Shibuya/Internal/Runner/Master.hs view
@@ -2,11 +2,10 @@ -- No PVP guarantees: anything here may change or disappear in any release. -- Application authors should import "Shibuya" instead. ----- Master process - central coordinator for queue processors.+-- Master handle - owns the shared supervisor and metrics registry for queue processors. -- Provides supervision, metrics collection, and control API. -- -- Architecture:--- - Master is an NQE Process that handles control messages -- - Holds a Supervisor for managing child processors -- - Maintains TVar MetricsMap for O(1) metrics access -- - Processors register their metrics TVars with the Master@@ -15,9 +14,6 @@     Master (..),     MasterState (..), -    -- * Control Messages-    MasterMessage (..),-     -- * Starting the Master     startMaster,     stopMaster,@@ -34,13 +30,7 @@   ) where -import Control.Concurrent.NQE.Process-  ( Inbox,-    Listen,-    Process (..),-    newInbox,-    receive,-  )+import Control.Concurrent.NQE.Process (Process (..)) import Control.Concurrent.NQE.Supervisor (Strategy (..), Supervisor) import Control.Concurrent.NQE.Supervisor qualified as Supervisor import Control.Concurrent.STM@@ -50,7 +40,6 @@     newTVarIO,     readTVar,   )-import Control.Monad (forever) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Effectful (Eff, IOE, liftIO, (:>))@@ -62,16 +51,7 @@     sampleMetrics,   ) import Shibuya.Prelude-import UnliftIO (Async, async, cancel, link)---- | Messages for the master process.-data MasterMessage-  = -- | Register a processor's metrics handle-    RegisterProcessor !ProcessorId !MetricsHandle !(Listen ())-  | -- | Unregister a processor-    UnregisterProcessor !ProcessorId !(Listen ())-  | -- | Shutdown all processors-    Shutdown !(Listen ())+import UnliftIO (cancel)  -- | Master state held in TVars. data MasterState = MasterState@@ -86,19 +66,15 @@   }   deriving (Generic) --- | Master handle - provides access to the master process.-data Master = Master-  { -- | The async handle for the master-    handle :: !(Async ()),-    -- | Direct access to master state-    state :: !MasterState,-    -- | Inbox for sending messages-    inbox :: !(Inbox MasterMessage)+-- | Master handle - owns the shared supervisor and metrics registry.+newtype Master = Master+  { -- | Direct access to master state+    state :: MasterState   }   deriving (Generic)  -- | Start the master process.--- Returns a handle for interacting with the master.+-- Returns a handle for accessing shared application state. -- The caller is responsible for calling stopMaster when done. startMaster :: (IOE :> es) => Strategy -> Eff es Master startMaster strategy = liftIO $ do@@ -111,52 +87,12 @@         IgnoreGraceful -> True         IgnoreAll -> False         Notify _ -> False-      masterState = MasterState metricsMapVar sup propagate--  masterInbox <- newInbox--  -- Start master loop-  masterHandle <- async $ masterLoop masterState masterInbox-  link masterHandle--  pure-    Master-      { handle = masterHandle,-        state = masterState,-        inbox = masterInbox-      }+  pure Master {state = MasterState metricsMapVar sup propagate}  -- | Stop the master and all child processors.--- Cancels the supervisor first (which cancels all children via NQE's stopAll),--- then cancels the master message loop.+-- Cancels the supervisor, which cancels all children via NQE's stopAll. stopMaster :: (IOE :> es) => Master -> Eff es ()-stopMaster master = liftIO $ do-  -- Cancel the supervisor first - this triggers NQE's stopAll which cancels all children-  cancel (getProcessAsync master.state.supervisor)-  -- Then cancel the master message loop-  cancel master.handle---- | The master process main loop.-masterLoop :: MasterState -> Inbox MasterMessage -> IO ()-masterLoop state inbox = forever $ do-  msg <- receive inbox-  handleMessage state msg---- | Handle a single master message.-handleMessage :: MasterState -> MasterMessage -> IO ()-handleMessage state msg = case msg of-  RegisterProcessor pid metricsHandle respond -> do-    atomically $ do-      modifyTVar' state.metrics $ Map.insert pid metricsHandle-    atomically $ respond ()-  UnregisterProcessor pid respond -> do-    atomically $ do-      modifyTVar' state.metrics $ Map.delete pid-    atomically $ respond ()-  Shutdown respond -> do-    -- Clear all processors-    atomically $ modifyTVar' state.metrics (const Map.empty)-    atomically $ respond ()+stopMaster master = liftIO $ cancel (getProcessAsync master.state.supervisor)  -- | Get metrics for all processors. getAllMetrics :: (IOE :> es) => Master -> Eff es MetricsMap
+ test-gc/Main.hs view
@@ -0,0 +1,70 @@+{-# LANGUAGE OverloadedStrings #-}++-- This probe owns its process: retaining an AppHandle for cleanup would hide+-- the bug, and a weak pointer cannot guarantee cleanup after collection.+module Main (main) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeException, displayException, try)+import Control.Monad (replicateM_)+import Effectful (liftIO, runEff)+import Shibuya.Adapter (Adapter (..))+import Shibuya.App+  ( AppConfig (..),+    ProcessorId (..),+    SupervisionStrategy (..),+    defaultAppConfig,+    mkProcessor,+    runApp,+    waitApp,+  )+import Shibuya.Core.Ack (AckDecision (..))+import Shibuya.Telemetry.Effect (runTracingNoop)+import Streamly.Data.Stream qualified as Stream+import System.Exit (die)+import System.Mem (performMajorGC)+import UnliftIO qualified as UIO++main :: IO ()+main = do+  started <- newEmptyMVar+  outcome <- try @SomeException $ UIO.timeout 5_000_000 $ UIO.race (gcWindow started) (worker started)+  case outcome of+    Left err -> die ("FAIL: bare waitApp died: " <> displayException err)+    Right Nothing -> die "FAIL: the GC observation window did not finish within five seconds"+    Right (Just (Right ())) -> die "FAIL: waitApp returned while the idle processor should still be running"+    Right (Just (Left ())) -> putStrLn "PASS: bare waitApp survives major collections"++gcWindow :: MVar () -> IO ()+gcWindow started = do+  -- Do not let a slow startup pass without exercising a running application.+  takeMVar started+  replicateM_ 5 $ do+    threadDelay 100_000+    performMajorGC+  -- Leave time for a linked exception from the last collection to arrive.+  threadDelay 200_000++worker :: MVar () -> IO ()+worker started = runEff $ runTracingNoop $ do+  -- A timer keeps the ingester alive just as a real adapter's poll delay does.+  -- An unreachable STM retry here would introduce a separate deadlock.+  let adapter =+        Adapter+          { adapterName = "gc-regression:idle",+            source = Stream.fromEffect $ liftIO $ do+              threadDelay 60_000_000+              fail "the idle adapter unexpectedly woke during the GC probe",+            shutdown = pure ()+          }+  result <-+    runApp+      defaultAppConfig {strategy = IgnoreFailures}+      [(ProcessorId "idle", mkProcessor adapter (\_ -> pure AckOk))]+  case result of+    Left err -> liftIO $ die ("runApp failed: " <> show err)+    Right app -> do+      liftIO $ putMVar started ()+      -- Deliberately no metrics server, retained handle, or subsequent stopApp.+      waitApp app
test/Shibuya/RunnerSpec.hs view
@@ -70,11 +70,7 @@           Left err -> pure $ Left err           Right appHandle -> do             waitApp appHandle-            -- Stop the app to cancel the (always-linked) master coordinator.-            -- Without this the idle master blocks forever on its mailbox and the-            -- RTS eventually raises BlockedIndefinitelyOnSTM, which propagates-            -- through the link as a flaky ExceptionInLinkedThread landing on-            -- whichever test happens to be running when GC fires.+            -- Stop the supervisor and its children so they do not outlive this test.             stopApp appHandle             pure $ Right () @@ -107,8 +103,7 @@             pure (decs, Left err)           Right appHandle -> do             waitApp appHandle-            -- See note in "processes messages from mock adapter": stop the app so-            -- the linked master does not deadlock and flake a later test.+            -- Stop the supervisor and its children so they do not outlive this test.             stopApp appHandle             decs <- liftIO $ readIORef tracking.trackedDecisions             pure (decs, Right ())@@ -141,11 +136,7 @@           Left err -> pure $ Left err           Right appHandle -> do             waitApp appHandle-            -- Stop the app to cancel the (always-linked) master coordinator.-            -- Without this the idle master blocks forever on its mailbox and the-            -- RTS eventually raises BlockedIndefinitelyOnSTM, which propagates-            -- through the link as a flaky ExceptionInLinkedThread landing on-            -- whichever test happens to be running when GC fires.+            -- Stop the supervisor and its children so they do not outlive this test.             stopApp appHandle             pure $ Right () @@ -200,11 +191,7 @@           Left err -> pure $ Left err           Right appHandle -> do             waitApp appHandle-            -- Stop the app to cancel the (always-linked) master coordinator.-            -- Without this the idle master blocks forever on its mailbox and the-            -- RTS eventually raises BlockedIndefinitelyOnSTM, which propagates-            -- through the link as a flaky ExceptionInLinkedThread landing on-            -- whichever test happens to be running when GC fires.+            -- Stop the supervisor and its children so they do not outlive this test.             stopApp appHandle             pure $ Right ()