Add supervisor with backoff restarts

This commit is contained in:
2026-08-20 19:51:17 +03:00
parent 9c355adf47
commit f8d6a844e4
6 changed files with 196 additions and 6 deletions
+9 -5
View File
@@ -1,5 +1,6 @@
{ mkDerivation, aeson, async, base, bytestring, hspec, lens { mkDerivation, aeson, annotated-exception, async, base, bytestring
, lens-aeson, lib, network, stm, text, time, websockets , hedgehog, hspec, hspec-hedgehog, lens, lens-aeson, lib, network
, stm, text, time, websockets
}: }:
mkDerivation { mkDerivation {
pname = "home-assistant-controller"; pname = "home-assistant-controller";
@@ -8,11 +9,14 @@ mkDerivation {
isLibrary = true; isLibrary = true;
isExecutable = true; isExecutable = true;
libraryHaskellDepends = [ libraryHaskellDepends = [
aeson async base bytestring lens lens-aeson network stm text time aeson annotated-exception async base bytestring lens lens-aeson
websockets network stm text time websockets
]; ];
executableHaskellDepends = [ base ]; executableHaskellDepends = [ base ];
testHaskellDepends = [ aeson async base hspec stm text ]; testHaskellDepends = [
aeson annotated-exception async base hedgehog hspec hspec-hedgehog
stm text time
];
license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause"; license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause";
mainProgram = "home-assistant-controller"; mainProgram = "home-assistant-controller";
} }
+9 -1
View File
@@ -64,6 +64,7 @@ library
, HomeAssistant.Runtime , HomeAssistant.Runtime
, HomeAssistant.Runtime.Bus , HomeAssistant.Runtime.Bus
, HomeAssistant.Runtime.Connection , HomeAssistant.Runtime.Connection
, HomeAssistant.Runtime.Supervisor
-- Modules included in this library but not exported. -- Modules included in this library but not exported.
-- other-modules: -- other-modules:
@@ -83,6 +84,7 @@ library
, time , time
, stm , stm
, async , async
, annotated-exception
-- Directories containing source files. -- Directories containing source files.
hs-source-dirs: src hs-source-dirs: src
@@ -125,6 +127,8 @@ test-suite home-assistant-controller-test
other-modules: BusSpec other-modules: BusSpec
, ConnectionSpec , ConnectionSpec
, RuntimeSpec , RuntimeSpec
, SupervisorSpec
, BackoffProp
-- LANGUAGE extensions used by modules in this package. -- LANGUAGE extensions used by modules in this package.
-- other-extensions: -- other-extensions:
@@ -146,4 +150,8 @@ test-suite home-assistant-controller-test
stm, stm,
aeson, aeson,
text, text,
async async,
hedgehog,
hspec-hedgehog,
annotated-exception,
time
+80
View File
@@ -0,0 +1,80 @@
{-# LANGUAGE ScopedTypeVariables #-}
module HomeAssistant.Runtime.Supervisor
( supervised
, Backoff(..)
, defaultBackoff
, backoffDelay
, nextAttempt
, Fatal(..)
) where
import Control.Concurrent (threadDelay)
import Control.Exception.Annotated
( Exception
, Handler (..)
, SomeException
, catches
, displayException
, throw
)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time (NominalDiffTime, diffUTCTime, getCurrentTime)
import Data.Void (Void)
-- | A failure that cannot be fixed by restarting; propagates out of
-- 'supervised' and terminates the process.
newtype Fatal = Fatal Text
deriving (Show)
instance Exception Fatal
data Backoff = Backoff
{ backoffBase :: NominalDiffTime -- ^ Delay before the first restart
, backoffCap :: NominalDiffTime -- ^ Maximum delay between restarts
, backoffQuiet :: NominalDiffTime -- ^ Uptime after which the delay resets
}
deriving (Eq, Show)
defaultBackoff :: Backoff
defaultBackoff = Backoff 0.1 30 30
-- | Delay before the @attempt@-th restart: doubles from base, clamped at cap.
backoffDelay :: Backoff -> Int -> NominalDiffTime
backoffDelay (Backoff base cap _) attempt = go (attempt - 1) base
where
go 0 d = d
go n d = go (n - 1) (min cap (d * 2))
-- | Attempt number to use after a crash that ran for the given uptime.
nextAttempt :: Backoff -> NominalDiffTime -> Int -> Int
nextAttempt (Backoff _ _ quiet) uptime attempt
| uptime >= quiet = 1
| otherwise = attempt + 1
-- | Runs the action forever, restarting it with backoff after synchronous
-- exceptions; async exceptions propagate. 'Fatal' is rethrown (crashing the
-- caller) rather than restarted. The action must never return normally and
-- must not be wrapped in checkpoints around 'Fatal'-throwing code: a
-- doubly-wrapped 'Fatal' is indistinguishable from a crash and would be
-- restarted instead of escalated.
supervised :: Text -> Backoff -> IO Void -> IO Void
supervised name backoff action = go 1
where
go attempt = do
start <- getCurrentTime
outcome <- (action >> pure (Nothing :: Maybe (Either Fatal SomeException))) `catches`
[ Handler $ \(f :: Fatal) -> pure (Just (Left f))
, Handler $ \(e :: SomeException) -> pure (Just (Right e))
]
case outcome of
Nothing -> error "unreachable: supervised action returned"
Just (Left f) -> throw f
Just (Right e) -> do
putStrLn $ "[" <> T.unpack name <> "] attempt " <> show attempt <> " crashed: " <> displayException e
let delay = backoffDelay backoff attempt
putStrLn $ "[" <> T.unpack name <> "] restarting in " <> show delay <> "s"
threadDelay (round (realToFrac delay * 1000000 :: Double))
end <- getCurrentTime
go (nextAttempt backoff (diffUTCTime end start) attempt)
+21
View File
@@ -0,0 +1,21 @@
module BackoffProp (spec) where
import Data.Maybe (listToMaybe)
import Data.Time (NominalDiffTime)
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import HomeAssistant.Runtime.Supervisor (Backoff (..), backoffDelay)
import Test.Hspec (Spec, describe, it)
import Test.Hspec.Hedgehog (hedgehog, forAll, (===))
spec :: Spec
spec = describe "backoffDelay" $
it "doubles from base, clamped at cap" $ hedgehog $ do
baseD <- forAll $ Gen.double (Range.constant 0.0001 10)
ratio <- forAll $ Gen.double (Range.constant 1 100)
let base = realToFrac baseD :: NominalDiffTime
cap = realToFrac (baseD * ratio) :: NominalDiffTime
backoff = Backoff base cap 1
delays = map (backoffDelay backoff) [1 .. 100 :: Int]
listToMaybe delays === Just (min cap base)
mapM_ (\(a, b) -> b === min cap (a * 2)) (zip delays (drop 1 delays))
+4
View File
@@ -1,12 +1,16 @@
module Main (main) where module Main (main) where
import Test.Hspec (hspec) import Test.Hspec (hspec)
import qualified BackoffProp
import qualified BusSpec import qualified BusSpec
import qualified ConnectionSpec import qualified ConnectionSpec
import qualified RuntimeSpec import qualified RuntimeSpec
import qualified SupervisorSpec
main :: IO () main :: IO ()
main = hspec $ do main = hspec $ do
BusSpec.spec BusSpec.spec
ConnectionSpec.spec ConnectionSpec.spec
RuntimeSpec.spec RuntimeSpec.spec
SupervisorSpec.spec
BackoffProp.spec
+73
View File
@@ -0,0 +1,73 @@
{-# LANGUAGE OverloadedStrings #-}
module SupervisorSpec (spec) where
import Control.Concurrent (newEmptyMVar, putMVar, readMVar, threadDelay)
import Control.Concurrent.Async (async, cancel, poll, waitCatch)
import Control.Exception (fromException)
import Control.Exception.Annotated (AnnotatedException (..), throw)
import Control.Monad (forever)
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import Data.Maybe (isJust, isNothing)
import HomeAssistant.Runtime.Supervisor
import Test.Hspec
tinyBackoff :: Backoff
tinyBackoff = Backoff 0.001 0.002 0.001
spec :: Spec
spec = describe "supervised" $ do
it "restarts a crashing action until it stays up" $ do
counter <- newIORef (0 :: Int)
up <- newEmptyMVar
let action = do
n <- atomicModifyIORef' counter (\c -> (c + 1, c + 1))
if n < 3
then ioError (userError "boom")
else do putMVar up (); forever (threadDelay 1000000)
sup <- async (supervised "test" tinyBackoff action)
readMVar up
threadDelay 50000
status <- poll sup
isNothing status `shouldBe` True
readIORef counter `shouldReturn` 3
cancel sup
it "rethrows Fatal instead of restarting" $ do
counter <- newIORef (0 :: Int)
let action = do
_ <- atomicModifyIORef' counter (\c -> (c + 1, c + 1))
throw (Fatal "auth_invalid")
sup <- async (supervised "test" tinyBackoff action)
res <- waitCatch sup
case res of
Left se -> case fromException se :: Maybe (AnnotatedException Fatal) of
Just _ -> pure ()
Nothing -> expectationFailure "expected Fatal to propagate"
Right _ -> expectationFailure "supervised returned"
threadDelay 50000
readIORef counter `shouldReturn` 1
it "does not restart on async exceptions" $ do
counter <- newIORef (0 :: Int)
let action = do
_ <- atomicModifyIORef' counter (\c -> (c + 1, c + 1))
forever (threadDelay 1000000)
sup <- async (supervised "test" tinyBackoff action)
threadDelay 100000
cancel sup
threadDelay 100000
readIORef counter `shouldReturn` 1
status <- poll sup
isJust status `shouldBe` True
describe "nextAttempt" $ do
it "resets after a quiet period" $
nextAttempt tinyBackoff 0.001 5 `shouldBe` 1
it "increments otherwise" $
nextAttempt tinyBackoff 0.0005 5 `shouldBe` 6
describe "backoffDelay" $ do
it "starts at base" $ backoffDelay tinyBackoff 1 `shouldBe` 0.001
it "doubles" $ backoffDelay tinyBackoff 2 `shouldBe` 0.002
it "clamps at cap" $ backoffDelay tinyBackoff 3 `shouldBe` 0.002