auto-update 0.1.6 → 0.2.6
raw patch · 16 files changed
Files
- ChangeLog.md +46/−4
- Control/AutoUpdate.hs +37/−168
- Control/AutoUpdate/Event.hs +124/−0
- Control/AutoUpdate/Internal.hs +11/−0
- Control/AutoUpdate/Thread.hs +133/−0
- Control/AutoUpdate/Types.hs +49/−0
- Control/AutoUpdate/Util.hs +0/−23
- Control/Debounce.hs +38/−28
- Control/Debounce/Internal.hs +236/−37
- Control/Reaper.hs +129/−83
- Control/Reaper/Internal.hs +28/−0
- Setup.hs +1/−0
- auto-update.cabal +10/−3
- test/Control/AutoUpdateSpec.hs +28/−26
- test/Control/DebounceSpec.hs +146/−40
- test/Control/ReaperSpec.hs +5/−4
ChangeLog.md view
@@ -1,14 +1,56 @@ # ChangeLog for auto-update +## 0.2.6++* Using the thread version of AutoUpdate for non-threaded RTS.+ [#1020](https://github.com/yesodweb/wai/pull/1020)++## 0.2.5++* Thread less autoupdate+ [#1018](https://github.com/yesodweb/wai/pull/1018)++## 0.2.4++* Simple refactoring.++## 0.2.3++* [#996](https://github.com/yesodweb/wai/pull/996):+ Refactored the `Control.Debounce` logic to not leak threads.+* [#996](https://github.com/yesodweb/wai/pull/996):+ Added extra `DebounceEdge` options for different types of debouncing.+ * `LeadingMute`: Action on first trigger, and ignore any triggers during cooldown+ * `TrailingDelay`: First trigger starts cooldown, and+ triggers during cooldown extend the cooldown. Action when cooldown expires.++## 0.2.2++* NewAPI: updateThreadName, reaperThreadName, debounceThreadName:+ Names can be given via this field to threads+ for GHC.Conc.Sync.listThreads.++## 0.2.1++* Labeling threads.++## 0.2.0++* Creating Reaper.Internal to export Reaper constructor.+* Hiding Reaper constructor.+* [#985](https://github.com/yesodweb/wai/pull/985):+ Add `reaperModify` to the `Reaper` API, allowing workload modification outside+ of the main `reaperAction` loop.+ ## 0.1.6 -* Add control of activation on leading vs. trailing edges for Control.Debounce- [#756](https://github.com/yesodweb/wai/pull/756)+* [#756](https://github.com/yesodweb/wai/pull/756):+ Add control of activation on leading vs. trailing edges for Control.Debounce ## 0.1.5 -* Using the Strict and StrictData language extensions for GHC >8.- [#752](https://github.com/yesodweb/wai/pull/752)+* [#752](https://github.com/yesodweb/wai/pull/752):+ Using the Strict and StrictData language extensions for GHC >8. ## 0.1.4.1
Control/AutoUpdate.hs view
@@ -1,17 +1,17 @@ {-# LANGUAGE CPP #-}--- | In a multithreaded environment, running actions on a regularly scheduled--- background thread can dramatically improve performance.++-- | In a multithreaded environment, sharing results of actions can dramatically improve performance. -- For example, web servers need to return the current time with each HTTP response. -- For a high-volume server, it's much faster for a dedicated thread to run every -- second, and write the current time to a shared 'IORef', than it is for each -- request to make its own call to 'getCurrentTime'. ----- But for a low-volume server, whose request frequency is less than once per --- second, that approach will result in /more/ calls to 'getCurrentTime' than +-- But for a low-volume server, whose request frequency is less than once per+-- second, that approach will result in /more/ calls to 'getCurrentTime' than -- necessary, and worse, kills idle GC. -- -- This library solves that problem by allowing you to define actions which will--- either be performed by a dedicated thread, or, in times of low volume, will +-- either be performed by a dedicated thread, or, in times of low volume, will -- be executed by the calling thread. -- -- Example usage:@@ -29,173 +29,42 @@ -- -- For more examples, <http://www.yesodweb.com/blog/2014/08/announcing-auto-update see the blog post introducing this library>. module Control.AutoUpdate (- -- * Type- UpdateSettings- , defaultUpdateSettings- -- * Accessors- , updateAction- , updateFreq- , updateSpawnThreshold- -- * Creation- , mkAutoUpdate- , mkAutoUpdateWithModify- ) where+ -- * Type+ UpdateSettings,+ defaultUpdateSettings, -#if __GLASGOW_HASKELL__ < 709-import Control.Applicative ((<*>))-#endif-import Control.Concurrent (forkIO, threadDelay)-import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar,- takeMVar, tryPutMVar)-import Control.Exception (SomeException, catch, mask_, throw,- try)-import Control.Monad (void)-import Data.IORef (newIORef, readIORef, writeIORef)+ -- * Accessors+ updateAction,+ updateFreq,+ updateSpawnThreshold,+ updateThreadName, --- | Default value for creating an 'UpdateSettings'.------ @since 0.1.0-defaultUpdateSettings :: UpdateSettings ()-defaultUpdateSettings = UpdateSettings- { updateFreq = 1000000- , updateSpawnThreshold = 3- , updateAction = return ()- }+ -- * Creation+ mkAutoUpdate,+ mkAutoUpdateWithModify,+)+where --- | Settings to control how values are updated.------ This should be constructed using 'defaultUpdateSettings' and record--- update syntax, e.g.:------ @--- let settings = 'defaultUpdateSettings' { 'updateAction' = 'Data.Time.Clock.getCurrentTime' }--- @------ @since 0.1.0-data UpdateSettings a = UpdateSettings- { updateFreq :: Int- -- ^ Microseconds between update calls. Same considerations as- -- 'threadDelay' apply.- --- -- Default: 1 second (1000000)- --- -- @since 0.1.0- , updateSpawnThreshold :: Int- -- ^ NOTE: This value no longer has any effect, since worker threads are- -- dedicated instead of spawned on demand.- --- -- Previously, this determined how many times the data must be requested- -- before we decide to spawn a dedicated thread.- --- -- Default: 3- --- -- @since 0.1.0- , updateAction :: IO a- -- ^ Action to be performed to get the current value.- --- -- Default: does nothing.- --- -- @since 0.1.0- }+import Control.AutoUpdate.Types+#ifdef mingw32_HOST_OS+import Control.AutoUpdate.Thread+#else+import qualified Control.AutoUpdate.Event as Event+import qualified Control.AutoUpdate.Thread as Thread --- | Generate an action which will either read from an automatically--- updated value, or run the update action in the current thread.------ @since 0.1.0+import GHC.Event+ mkAutoUpdate :: UpdateSettings a -> IO (IO a)-mkAutoUpdate us = mkAutoUpdateHelper us Nothing+mkAutoUpdate settings = do+ mmgr <- getSystemEventManager+ case mmgr of+ Nothing -> Thread.mkAutoUpdate settings+ Just _m -> Event.mkAutoUpdate settings --- | Generate an action which will either read from an automatically--- updated value, or run the update action in the current thread if--- the first time or the provided modify action after that.------ @since 0.1.4 mkAutoUpdateWithModify :: UpdateSettings a -> (a -> IO a) -> IO (IO a)-mkAutoUpdateWithModify us f = mkAutoUpdateHelper us (Just f)--mkAutoUpdateHelper :: UpdateSettings a -> Maybe (a -> IO a) -> IO (IO a)-mkAutoUpdateHelper us updateActionModify = do- -- A baton to tell the worker thread to generate a new value.- needsRunning <- newEmptyMVar-- -- The initial response variable. Response variables allow the requesting- -- thread to block until a value is generated by the worker thread.- responseVar0 <- newEmptyMVar-- -- The current value, if available. We start off with a Left value- -- indicating no value is available, and the above-created responseVar0 to- -- give a variable to block on.- currRef <- newIORef $ Left responseVar0-- -- This is used to set a value in the currRef variable when the worker- -- thread exits. In reality, that value should never be used, since the- -- worker thread exiting only occurs if an async exception is thrown, which- -- should only occur if there are no references to needsRunning left.- -- However, this handler will make error messages much clearer if there's a- -- bug in the implementation.- let fillRefOnExit f = do- eres <- try f- case eres of- Left e -> writeIORef currRef $ error $- "Control.AutoUpdate.mkAutoUpdate: worker thread exited with exception: "- ++ show (e :: SomeException)- Right () -> writeIORef currRef $ error $- "Control.AutoUpdate.mkAutoUpdate: worker thread exited normally, "- ++ "which should be impossible due to usage of infinite loop"-- -- fork the worker thread immediately. Note that we mask async exceptions,- -- but *not* in an uninterruptible manner. This will allow a- -- BlockedIndefinitelyOnMVar exception to still be thrown, which will take- -- down this thread when all references to the returned function are- -- garbage collected, and therefore there is no thread that can fill the- -- needsRunning MVar.- --- -- Note that since we throw away the ThreadId of this new thread and never- -- calls myThreadId, normal async exceptions can never be thrown to it,- -- only RTS exceptions.- mask_ $ void $ forkIO $ fillRefOnExit $ do- -- This infinite loop makes up out worker thread. It takes an a- -- responseVar value where the next value should be putMVar'ed to for- -- the benefit of any requesters currently blocked on it.- let loop responseVar maybea = do- -- block until a value is actually needed- takeMVar needsRunning-- -- new value requested, so run the updateAction- a <- catchSome $ maybe (updateAction us) id (updateActionModify <*> maybea)-- -- we got a new value, update currRef and lastValue- writeIORef currRef $ Right a- putMVar responseVar a-- -- delay until we're needed again- threadDelay $ updateFreq us-- -- delay's over. create a new response variable and set currRef- -- to use it, so that the next requester will block on that- -- variable. Then loop again with the updated response- -- variable.- responseVar' <- newEmptyMVar- writeIORef currRef $ Left responseVar'- loop responseVar' (Just a)-- -- Kick off the loop, with the initial responseVar0 variable.- loop responseVar0 Nothing-- return $ do- mval <- readIORef currRef- case mval of- Left responseVar -> do- -- no current value, force the worker thread to run...- void $ tryPutMVar needsRunning ()-- -- and block for the result from the worker- readMVar responseVar- -- we have a current value, use it- Right val -> return val---- | Turn a runtime exception into an impure exception, so that all 'IO'--- actions will complete successfully. This simply defers the exception until--- the value is forced.-catchSome :: IO a -> IO a-catchSome act = Control.Exception.catch act $ \e -> return $ throw (e :: SomeException)+mkAutoUpdateWithModify settings f = do+ mmgr <- getSystemEventManager+ case mmgr of+ Nothing -> Thread.mkAutoUpdateWithModify settings f+ Just _m -> Event.mkAutoUpdateWithModify settings f+#endif
+ Control/AutoUpdate/Event.hs view
@@ -0,0 +1,124 @@+{-# LANGUAGE RecordWildCards #-}++module Control.AutoUpdate.Event (+ -- * Creation+ mkAutoUpdate,+ mkAutoUpdateWithModify,++ -- * Internal+ UpdateState (..),+ mkClosableAutoUpdate,+ mkClosableAutoUpdate',+)+where++import Control.Concurrent.STM+import Control.Monad+import Data.IORef+import GHC.Event (getSystemTimerManager, registerTimeout, unregisterTimeout)++import Control.AutoUpdate.Types++--------------------------------------------------------------------------------++-- | Generate an action which will either read from an automatically+-- updated value, or run the update action in the current thread.+--+-- @since 0.1.0+mkAutoUpdate :: UpdateSettings a -> IO (IO a)+mkAutoUpdate = mkAutoUpdateThings $ \g _ _ -> g++-- | Generate an action which will either read from an automatically+-- updated value, or run the update action in the current thread if+-- the first time or the provided modify action after that.+--+-- @since 0.1.4+mkAutoUpdateWithModify :: UpdateSettings a -> (a -> IO a) -> IO (IO a)+mkAutoUpdateWithModify us f = mkAutoUpdateThingsWithModify (\g _ _ -> g) us f++--------------------------------------------------------------------------------++{- FOURMOLU_DISABLE -}+data UpdateState a =+ UpdateState+ { usUpdateAction_ :: a -> IO a+ , usLastResult_ :: IORef a+ , usIntervalMicro_ :: Int+ , usTimeHasCome_ :: TVar Bool+ , usDeleteTimeout_ :: IORef (IO ())+ }+{- FOURMOLU_ENABLE -}++--------------------------------------------------------------------------------++mkAutoUpdateThings+ :: (IO a -> IO () -> UpdateState a -> b) -> UpdateSettings a -> IO b+mkAutoUpdateThings mk settings@UpdateSettings{..} =+ mkAutoUpdateThingsWithModify mk settings (const updateAction)++mkAutoUpdateThingsWithModify+ :: (IO a -> IO () -> UpdateState a -> b) -> UpdateSettings a -> (a -> IO a) -> IO b+mkAutoUpdateThingsWithModify mk settings update1 = do+ us <- openUpdateState settings update1+ pure $ mk (getUpdateResult us) (closeUpdateState us) us++--------------------------------------------------------------------------------++-- $setup+-- >>> :set -XNumericUnderscores+-- >>> import Control.Concurrent++-- |+-- >>> iref <- newIORef (0 :: Int)+-- >>> action = modifyIORef iref (+ 1) >> readIORef iref+-- >>> (getValue, closeState) <- mkClosableAutoUpdate $ defaultUpdateSettings { updateFreq = 200_000, updateAction = action }+-- >>> getValue+-- 1+-- >>> threadDelay 100_000 >> getValue+-- 1+-- >>> threadDelay 200_000 >> getValue+-- 2+-- >>> closeState+mkClosableAutoUpdate :: UpdateSettings a -> IO (IO a, IO ())+mkClosableAutoUpdate = mkAutoUpdateThings $ \g c _ -> (g, c)++-- | provide `UpdateState` for debugging+mkClosableAutoUpdate' :: UpdateSettings a -> IO (IO a, IO (), UpdateState a)+mkClosableAutoUpdate' = mkAutoUpdateThings (,,)++--------------------------------------------------------------------------------++mkDeleteTimeout :: TVar Bool -> Int -> IO (IO ())+mkDeleteTimeout thc micro = do+ mgr <- getSystemTimerManager+ key <- registerTimeout mgr micro (atomically $ writeTVar thc True)+ pure $ unregisterTimeout mgr key++openUpdateState :: UpdateSettings a -> (a -> IO a) -> IO (UpdateState a)+openUpdateState UpdateSettings{..} update1 = do+ thc <- newTVarIO False+ UpdateState update1+ <$> (newIORef =<< updateAction)+ <*> pure updateFreq+ <*> pure thc+ <*> (newIORef =<< mkDeleteTimeout thc updateFreq)++closeUpdateState :: UpdateState a -> IO ()+closeUpdateState UpdateState{..} = do+ delete <- readIORef usDeleteTimeout_+ delete++onceOnTimeHasCome :: UpdateState a -> IO () -> IO ()+onceOnTimeHasCome UpdateState{..} action = do+ action' <- atomically $ do+ timeHasCome <- readTVar usTimeHasCome_+ when timeHasCome $ writeTVar usTimeHasCome_ False+ pure $ when timeHasCome action+ action'++getUpdateResult :: UpdateState a -> IO a+getUpdateResult us@UpdateState{..} = do+ onceOnTimeHasCome us $ do+ writeIORef usLastResult_ =<< usUpdateAction_ =<< readIORef usLastResult_+ writeIORef usDeleteTimeout_ =<< mkDeleteTimeout usTimeHasCome_ usIntervalMicro_+ readIORef usLastResult_
+ Control/AutoUpdate/Internal.hs view
@@ -0,0 +1,11 @@+{-# LANGUAGE RecordWildCards #-}++module Control.AutoUpdate.Internal (+ -- * Debugging+ UpdateState (..),+ mkClosableAutoUpdate,+ mkClosableAutoUpdate',+)+where++import Control.AutoUpdate.Event
+ Control/AutoUpdate/Thread.hs view
@@ -0,0 +1,133 @@+module Control.AutoUpdate.Thread (+ -- * Creation+ mkAutoUpdate,+ mkAutoUpdateWithModify,+) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (+ newEmptyMVar,+ putMVar,+ readMVar,+ takeMVar,+ tryPutMVar,+ )+import Control.Exception (+ SomeException,+ catch,+ mask_,+ throw,+ try,+ )+import Control.Monad (void)+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Maybe (fromMaybe)+import GHC.Conc.Sync (labelThread)++import Control.AutoUpdate.Types++-- | Generate an action which will either read from an automatically+-- updated value, or run the update action in the current thread.+--+-- @since 0.1.0+mkAutoUpdate :: UpdateSettings a -> IO (IO a)+mkAutoUpdate us = mkAutoUpdateHelper us Nothing++-- | Generate an action which will either read from an automatically+-- updated value, or run the update action in the current thread if+-- the first time or the provided modify action after that.+--+-- @since 0.1.4+mkAutoUpdateWithModify :: UpdateSettings a -> (a -> IO a) -> IO (IO a)+mkAutoUpdateWithModify us f = mkAutoUpdateHelper us (Just f)++mkAutoUpdateHelper :: UpdateSettings a -> Maybe (a -> IO a) -> IO (IO a)+mkAutoUpdateHelper us updateActionModify = do+ -- A baton to tell the worker thread to generate a new value.+ needsRunning <- newEmptyMVar++ -- The initial response variable. Response variables allow the requesting+ -- thread to block until a value is generated by the worker thread.+ responseVar0 <- newEmptyMVar++ -- The current value, if available. We start off with a Left value+ -- indicating no value is available, and the above-created responseVar0 to+ -- give a variable to block on.+ currRef <- newIORef $ Left responseVar0++ -- This is used to set a value in the currRef variable when the worker+ -- thread exits. In reality, that value should never be used, since the+ -- worker thread exiting only occurs if an async exception is thrown, which+ -- should only occur if there are no references to needsRunning left.+ -- However, this handler will make error messages much clearer if there's a+ -- bug in the implementation.+ let fillRefOnExit f = do+ eres <- try f+ case eres of+ Left e ->+ writeIORef currRef $+ error $+ "Control.AutoUpdate.mkAutoUpdate: worker thread exited with exception: "+ ++ show (e :: SomeException)+ Right () ->+ writeIORef currRef $+ error $+ "Control.AutoUpdate.mkAutoUpdate: worker thread exited normally, "+ ++ "which should be impossible due to usage of infinite loop"++ -- fork the worker thread immediately. Note that we mask async exceptions,+ -- but *not* in an uninterruptible manner. This will allow a+ -- BlockedIndefinitelyOnMVar exception to still be thrown, which will take+ -- down this thread when all references to the returned function are+ -- garbage collected, and therefore there is no thread that can fill the+ -- needsRunning MVar.+ --+ -- Note that since we throw away the ThreadId of this new thread and never+ -- calls myThreadId, normal async exceptions can never be thrown to it,+ -- only RTS exceptions.+ tid <- mask_ $ forkIO $ fillRefOnExit $ do+ -- This infinite loop makes up out worker thread. It takes an a+ -- responseVar value where the next value should be putMVar'ed to for+ -- the benefit of any requesters currently blocked on it.+ let loop responseVar maybea = do+ -- block until a value is actually needed+ takeMVar needsRunning++ -- new value requested, so run the updateAction+ a <- catchSome $ fromMaybe (updateAction us) (updateActionModify <*> maybea)++ -- we got a new value, update currRef and lastValue+ writeIORef currRef $ Right a+ putMVar responseVar a++ -- delay until we're needed again+ threadDelay $ updateFreq us++ -- delay's over. create a new response variable and set currRef+ -- to use it, so that the next requester will block on that+ -- variable. Then loop again with the updated response+ -- variable.+ responseVar' <- newEmptyMVar+ writeIORef currRef $ Left responseVar'+ loop responseVar' (Just a)++ -- Kick off the loop, with the initial responseVar0 variable.+ loop responseVar0 Nothing+ labelThread tid $ updateThreadName us+ return $ do+ mval <- readIORef currRef+ case mval of+ Left responseVar -> do+ -- no current value, force the worker thread to run...+ void $ tryPutMVar needsRunning ()++ -- and block for the result from the worker+ readMVar responseVar+ -- we have a current value, use it+ Right val -> return val++-- | Turn a runtime exception into an impure exception, so that all 'IO'+-- actions will complete successfully. This simply defers the exception until+-- the value is forced.+catchSome :: IO a -> IO a+catchSome act = Control.Exception.catch act $ \e -> return $ throw (e :: SomeException)
+ Control/AutoUpdate/Types.hs view
@@ -0,0 +1,49 @@+module Control.AutoUpdate.Types where++-- | Settings to control how values are updated.+--+-- This should be constructed using 'defaultUpdateSettings' and record+-- update syntax, e.g.:+--+-- @+-- let settings = 'defaultUpdateSettings' { 'updateAction' = 'Data.Time.Clock.getCurrentTime' }+-- @+--+-- @since 0.1.0+data UpdateSettings a = UpdateSettings+ { updateFreq :: Int+ -- ^ Microseconds between update calls. Same considerations as+ -- 'threadDelay' apply.+ --+ -- Default: 1000000 microseconds (1 second)+ --+ -- @since 0.1.0+ , updateSpawnThreshold :: Int+ -- ^ Obsoleted field.+ --+ -- @since 0.1.0+ , updateAction :: IO a+ -- ^ Action to be performed to get the current value.+ --+ -- Default: does nothing.+ --+ -- @since 0.1.0+ , updateThreadName :: String+ -- ^ Label of the thread being forked.+ --+ -- Default: @"AutoUpdate"@+ --+ -- @since 0.2.2+ }++-- | Default value for creating an 'UpdateSettings'.+--+-- @since 0.1.0+defaultUpdateSettings :: UpdateSettings ()+defaultUpdateSettings =+ UpdateSettings+ { updateFreq = 1000000+ , updateSpawnThreshold = 3+ , updateAction = return ()+ , updateThreadName = "AutoUpdate"+ }
− Control/AutoUpdate/Util.hs
@@ -1,23 +0,0 @@-{-# LANGUAGE CPP #-}-module Control.AutoUpdate.Util- ( atomicModifyIORef'- ) where--#ifndef MIN_VERSION_base-#define MIN_VERSION_base(x,y,z) 1-#endif--#if MIN_VERSION_base(4,6,0)-import Data.IORef (atomicModifyIORef')-#else-import Data.IORef (IORef, atomicModifyIORef)--- | Strict version of 'atomicModifyIORef'. This forces both the value stored--- in the 'IORef' as well as the value returned.-atomicModifyIORef' :: IORef a -> (a -> (a,b)) -> IO b-atomicModifyIORef' ref f = do- c <- atomicModifyIORef ref- (\x -> let (a, b) = f x -- Lazy application of "f"- in (a, a `seq` b)) -- Lazy application of "seq"- -- The following forces "a `seq` b", so it also forces "f x".- c `seq` return c-#endif
Control/Debounce.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE ScopedTypeVariables #-} -- | Debounce an action, ensuring it doesn't occur more than once for a given -- period of time. --@@ -8,53 +7,64 @@ -- Example usage: -- -- @--- printString <- 'mkDebounce' 'defaultDebounceSettings'+-- > printString <- 'mkDebounce' 'defaultDebounceSettings' -- { 'debounceAction' = putStrLn "Running action" -- , 'debounceFreq' = 5000000 -- 5 seconds -- , 'debounceEdge' = 'DI.trailingEdge' -- Trigger on the trailing edge -- }--- @------ >>> printString+-- > printString -- Running action--- >>> printString--- <Wait five seconds>+-- > printString+-- \<Wait five seconds> -- Running action+-- @ -- -- See the fast-logger package ("System.Log.FastLogger") for real-world usage. -- -- @since 0.1.2-module Control.Debounce- ( -- * Type- DI.DebounceSettings- , defaultDebounceSettings- -- * Accessors- , DI.debounceFreq- , DI.debounceAction- , DI.debounceEdge- , DI.leadingEdge- , DI.trailingEdge- -- * Creation- , mkDebounce- ) where+module Control.Debounce (+ -- * Creation+ mkDebounce, -import Control.Concurrent (newEmptyMVar, threadDelay)+ -- * Settings+ DI.DebounceSettings,+ defaultDebounceSettings,++ -- ** Accessors+ DI.debounceFreq,+ DI.debounceAction,+ DI.debounceEdge,+ DI.debounceThreadName,++ -- ** Edge types+ DI.leadingEdge,+ DI.leadingMuteEdge,+ DI.trailingEdge,+ DI.trailingDelayEdge,+) where++import Control.Concurrent (newMVar, threadDelay) import qualified Control.Debounce.Internal as DI -- | Default value for creating a 'DebounceSettings'. -- -- @since 0.1.2 defaultDebounceSettings :: DI.DebounceSettings-defaultDebounceSettings = DI.DebounceSettings- { DI.debounceFreq = 1000000- , DI.debounceAction = return ()- , DI.debounceEdge = DI.leadingEdge- }+defaultDebounceSettings =+ DI.DebounceSettings+ { DI.debounceFreq = 1000000+ , DI.debounceAction = return ()+ , DI.debounceEdge = DI.leadingEdge+ , DI.debounceThreadName = "Debounce"+ } -- | Generate an action which will trigger the debounced action to be performed. --+-- /N.B. The generated action will always immediately return, regardless of the 'debounceFreq',/+-- /as the debounced action (and the delay\/cooldown) is always performed in a separate thread./+-- -- @since 0.1.2 mkDebounce :: DI.DebounceSettings -> IO (IO ()) mkDebounce settings = do- baton <- newEmptyMVar- DI.mkDebounceInternal baton threadDelay settings+ baton <- newMVar ()+ DI.mkDebounceInternal baton threadDelay settings
Control/Debounce/Internal.hs view
@@ -2,17 +2,28 @@ -- | Unstable API which exposes internals for testing. module Control.Debounce.Internal (- DebounceSettings(..)- , DebounceEdge(..)- , leadingEdge- , trailingEdge- , mkDebounceInternal- ) where+ DebounceSettings (..),+ DebounceEdge (..),+ leadingEdge,+ leadingMuteEdge,+ trailingEdge,+ trailingDelayEdge,+ mkDebounceInternal,+) where -import Control.Concurrent (forkIO)-import Control.Concurrent.MVar (takeMVar, tryPutMVar, tryTakeMVar, MVar)-import Control.Exception (SomeException, handle, mask_)-import Control.Monad (forever, void)+import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (+ MVar,+ newEmptyMVar,+ putMVar,+ tryPutMVar,+ tryTakeMVar,+ )+import Control.Exception (SomeException, handle, mask_)+import Control.Monad (void, when)+import GHC.Clock (getMonotonicTimeNSec)+import GHC.Conc (atomically, newTVarIO, readTVar, readTVarIO, writeTVar)+import GHC.Conc.Sync (labelThread) -- | Settings to control how debouncing should work. --@@ -25,7 +36,7 @@ -- -- @since 0.1.2 data DebounceSettings = DebounceSettings- { debounceFreq :: Int+ { debounceFreq :: Int -- ^ Length of the debounce timeout period in microseconds. -- -- Default: 1 second (1000000)@@ -43,56 +54,244 @@ -- ^ Whether to perform the action on the leading edge or trailing edge of -- the timeout. --- -- Default: 'trailingEdge'.+ -- Default: 'leadingEdge'. -- -- @since 0.1.6+ , debounceThreadName :: String+ -- ^ Label of the thread spawned when debouncing.+ --+ -- Default: @"Debounce"@.+ --+ -- @since 0.2.2 } -- | Setting to control whether the action happens at the leading and/or trailing -- edge of the timeout. -- -- @since 0.1.6-data DebounceEdge =- Leading- -- ^ Perform the action immediately, and then begin a cooldown period.- -- If the trigger happens again during the cooldown, wait until the end of the cooldown- -- and then perform the action again, then enter a new cooldown period.- | Trailing- -- ^ Start a cooldown period and perform the action when the period ends. If another trigger- -- happens during the cooldown, it has no effect.- deriving (Show, Eq)-+data DebounceEdge+ = -- | Perform the action immediately, and then begin a cooldown period.+ -- If the trigger happens again during the cooldown, wait until the end of the cooldown+ -- and then perform the action again, then enter a new cooldown period.+ Leading+ | -- | Perform the action immediately, and then begin a cooldown period.+ -- If the trigger happens again during the cooldown, it is ignored.+ LeadingMute+ | -- | Start a cooldown period and perform the action when the period ends. If another trigger+ -- happens during the cooldown, it has no effect.+ Trailing+ | -- | Start a cooldown period and perform the action when the period ends. If another trigger+ -- happens during the cooldown, it restarts the cooldown again.+ TrailingDelay+ deriving (Show, Eq) -- | Perform the action immediately, and then begin a cooldown period. -- If the trigger happens again during the cooldown, wait until the end of the cooldown -- and then perform the action again, then enter a new cooldown period. --+-- Example of how this style debounce works:+--+-- > ! = function execution+-- > . = cooldown period+-- > X = debounced code execution+-- >+-- > ! ! ! !+-- > ....... ....... ....... .......+-- > X X X X+-- -- @since 0.1.6 leadingEdge :: DebounceEdge leadingEdge = Leading --- | Start a cooldown period and perform the action when the period ends. If another trigger--- happens during the cooldown, it has no effect.+-- | Perform the action immediately, and then begin a cooldown period.+-- If the trigger happens again during the cooldown, it is ignored. --+-- Example of how this style debounce works:+--+-- > ! = function execution+-- > . = cooldown period+-- > X = debounced code execution+-- >+-- > ! ! ! !+-- > ....... .......+-- > X X+-- -- @since 0.1.6+leadingMuteEdge :: DebounceEdge+leadingMuteEdge = LeadingMute++-- | Start a cooldown period and perform the action when the period ends.+-- If another trigger happens during the cooldown, it has no effect.+--+-- Example of how this style debounce works:+--+-- @+-- ! = function execution+-- . = cooldown period+-- X = debounced code execution+--+-- ! ! ! !+-- ....... .......+-- X X+-- @+--+-- @since 0.1.6 trailingEdge :: DebounceEdge trailingEdge = Trailing -mkDebounceInternal :: MVar () -> (Int -> IO ()) -> DebounceSettings -> IO (IO ())-mkDebounceInternal baton delayFn (DebounceSettings freq action edge) = do- mask_ $ void $ forkIO $ forever $ do- takeMVar baton- case edge of- Leading -> do+-- | Start a cooldown period and perform the action when the period ends.+-- If another trigger happens during the cooldown, it restarts the cooldown again.+--+-- /N.B. If a trigger happens DURING the 'debounceAction' it starts a new cooldown./+-- /So if the 'debounceAction' takes longer than the 'debounceFreq', it might run/+-- /again before the previous action has ended./+--+-- Example of how this style debounce works:+--+-- @+-- ! = function execution+-- . = cooldown period+-- X = debounced code execution+--+-- ! ! ! !+-- ....... ...............+-- X X+-- @+--+-- @since 0.1.6+trailingDelayEdge :: DebounceEdge+trailingDelayEdge = TrailingDelay++mkDebounceInternal+ :: MVar () -> (Int -> IO ()) -> DebounceSettings -> IO (IO ())+mkDebounceInternal baton delayFn (DebounceSettings freq action edge name) =+ case edge of+ Leading -> leadingDebounce <$> newEmptyMVar+ LeadingMute -> pure leadingMuteDebounce+ Trailing -> pure trailingDebounce+ TrailingDelay -> trailingDelayDebounce <$> newTVarIO minBound+ where+ -- LEADING+ --+ -- 1) try take baton to start+ -- 2) succes -> empty trigger & start worker, failed -> fill trigger+ -- 3) worker do action+ -- 4) delay+ -- 5) try take trigger+ -- 6) success -> repeat action, failed -> put baton back+ leadingDebounce trigger = do+ -- 1)+ success <- tryTakeMVar baton+ case success of+ -- 2)+ Nothing -> void $ tryPutMVar trigger ()+ Just () -> do+ void $ tryTakeMVar trigger+ forkAndLabel loop+ where+ loop = do+ -- 3) ignoreExc action- delayFn freq- Trailing -> do+ -- 4) delayFn freq- -- Empty the baton of any other activations during the interval- void $ tryTakeMVar baton- ignoreExc action-- return $ void $ tryPutMVar baton ()+ -- 5)+ isTriggered <- tryTakeMVar trigger+ case isTriggered of+ -- 6)+ Nothing -> putMVar baton ()+ Just () -> loop+ -- LEADING MUTE+ --+ -- 1) try take baton to start+ -- 2) success -> start worker, failed -> die+ -- 3) worker delay+ -- 4) do action+ -- 5) put baton back+ leadingMuteDebounce = do+ -- 1)+ success <- tryTakeMVar baton+ case success of+ -- 2)+ Nothing -> pure ()+ Just () ->+ forkAndLabel $ do+ -- 3)+ ignoreExc action+ -- 4)+ delayFn freq+ -- 5)+ putMVar baton ()+ -- TRAILING+ --+ -- 1) try take baton to start+ -- 2) success -> start worker, failed -> die+ -- 3) worker delay+ -- 4) do action+ -- 5) put baton back+ trailingDebounce = do+ -- 1)+ success <- tryTakeMVar baton+ case success of+ -- 2)+ Nothing -> pure ()+ Just () ->+ forkAndLabel $ do+ -- 3)+ delayFn freq+ -- 4)+ ignoreExc action+ -- 5)+ putMVar baton ()+ -- TRAILING DELAY+ --+ -- 1) get current time -> /now/+ -- 2) try take baton to start+ -- 3) success -> set time var to /now/ & start worker, failed -> update time var to /now/+ -- 4) worker waits minimum delay+ -- 5) check diff of time var with /now/+ -- 6) less -> wait the difference, same/more -> do action+ -- 7) after action, recheck if there was any trigger+ -- 8) put baton back+ trailingDelayDebounce timeTVar = do+ -- 1)+ now <- getMonotonicTimeNSec+ -- 2)+ success <- tryTakeMVar baton+ case success of+ -- 3)+ Nothing -> atomically $ do+ oldTime <- readTVar timeTVar+ when (oldTime < now) $ writeTVar timeTVar now+ Just () -> do+ atomically $ writeTVar timeTVar now+ forkAndLabel $ loop freq+ where+ loop delay = do+ -- 4)+ delayFn delay+ lastTrigger <- readTVarIO timeTVar+ now <- getMonotonicTimeNSec+ -- 5)+ let diff = fromIntegral (now - lastTrigger) `div` 1000+ shouldWait = diff < freq+ if shouldWait+ -- 6)+ then loop $ freq - diff+ else do+ ignoreExc action+ timeAfterAction <- readTVarIO timeTVar+ -- 7)+ let wasTriggered = timeAfterAction > now+ if wasTriggered+ then do+ updatedNow <- getMonotonicTimeNSec+ let newDiff = fromIntegral (updatedNow - timeAfterAction) `div` 1000+ loop $ freq - newDiff+ -- 8)+ else putMVar baton ()+ forkAndLabel act = do+ tid <- mask_ $ forkIO act+ labelThread tid name ignoreExc :: IO () -> IO () ignoreExc = handle $ \(_ :: SomeException) -> return ()
Control/Reaper.hs view
@@ -1,5 +1,5 @@-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE RecordWildCards #-} -- | This module provides the ability to create reapers: dedicated cleanup -- threads. These threads will automatically spawn and die based on the@@ -12,30 +12,41 @@ -- For real-world usage, search the <https://github.com/yesodweb/wai WAI family of packages> -- for imports of "Control.Reaper". module Control.Reaper (- -- * Example: Regularly cleaning a cache- -- $example1+ -- * Example: Regularly cleaning a cache+ -- $example1 - -- * Settings- ReaperSettings- , defaultReaperSettings- -- * Accessors- , reaperAction- , reaperDelay- , reaperCons- , reaperNull- , reaperEmpty- -- * Type- , Reaper(..)- -- * Creation- , mkReaper- -- * Helper- , mkListAction- ) where+ -- * Settings+ ReaperSettings,+ defaultReaperSettings, -import Control.AutoUpdate.Util (atomicModifyIORef')-import Control.Concurrent (forkIO, threadDelay, killThread, ThreadId)+ -- * Accessors+ reaperAction,+ reaperDelay,+ reaperCons,+ reaperNull,+ reaperEmpty,+ reaperThreadName,++ -- * Type+ Reaper,+ reaperAdd,+ reaperRead,+ reaperModify,+ reaperStop,+ reaperKill,++ -- * Creation+ mkReaper,++ -- * Helper+ mkListAction,+) where++import Control.Concurrent (ThreadId, forkIO, killThread, threadDelay) import Control.Exception (mask_)-import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Control.Reaper.Internal+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)+import GHC.Conc.Sync (labelThread) -- | Settings for creating a reaper. This type has two parameters: -- @workload@ gives the entire workload, whereas @item@ gives an@@ -83,6 +94,12 @@ -- Default: empty list. -- -- @since 0.1.1+ , reaperThreadName :: String+ -- ^ Label of the thread spawned by the reaper.+ --+ -- Default: @"Reaper"@.+ --+ -- @since 0.2.2 } -- | Default @ReaperSettings@ value, biased towards having a list of work@@ -90,30 +107,22 @@ -- -- @since 0.1.1 defaultReaperSettings :: ReaperSettings [item] item-defaultReaperSettings = ReaperSettings- { reaperAction = \wl -> return (wl ++)- , reaperDelay = 30000000- , reaperCons = (:)- , reaperNull = null- , reaperEmpty = []- }---- | A data structure to hold reaper APIs.-data Reaper workload item = Reaper {- -- | Adding an item to the workload- reaperAdd :: item -> IO ()- -- | Reading workload.- , reaperRead :: IO workload- -- | Stopping the reaper thread if exists.- -- The current workload is returned.- , reaperStop :: IO workload- -- | Killing the reaper thread immediately if exists.- , reaperKill :: IO ()- }+defaultReaperSettings =+ ReaperSettings+ { reaperAction = \wl -> return (wl ++)+ , reaperDelay = 30000000+ , reaperCons = (:)+ , reaperNull = null+ , reaperEmpty = []+ , reaperThreadName = "Reaper"+ } -- | State of reaper.-data State workload = NoReaper -- ^ No reaper thread- | Workload !workload -- ^ The current jobs+data State workload+ = -- | No reaper thread+ NoReaper+ | -- | The current jobs+ Workload !workload -- | Create a reaper addition function. This function can be used to add -- new items to the workload. Spawning of reaper threads will be handled@@ -123,52 +132,71 @@ mkReaper :: ReaperSettings workload item -> IO (Reaper workload item) mkReaper settings@ReaperSettings{..} = do stateRef <- newIORef NoReaper- tidRef <- newIORef Nothing- return Reaper {- reaperAdd = add settings stateRef tidRef- , reaperRead = readRef stateRef- , reaperStop = stop stateRef- , reaperKill = kill tidRef- }+ tidRef <- newIORef Nothing+ return+ Reaper+ { reaperAdd = add settings stateRef tidRef+ , reaperRead = readRef stateRef+ , reaperModify = modifyRef stateRef+ , reaperStop = stop stateRef+ , reaperKill = kill tidRef+ } where readRef stateRef = do mx <- readIORef stateRef case mx of- NoReaper -> return reaperEmpty+ NoReaper -> return reaperEmpty Workload wl -> return wl+ modifyRef stateRef modifier = atomicModifyIORef' stateRef $ \mx ->+ case mx of+ NoReaper ->+ (NoReaper, reaperEmpty)+ Workload wl ->+ let !wl' = modifier wl+ in (Workload wl', wl') stop stateRef = atomicModifyIORef' stateRef $ \mx -> case mx of- NoReaper -> (NoReaper, reaperEmpty)+ NoReaper -> (NoReaper, reaperEmpty) Workload x -> (Workload reaperEmpty, x) kill tidRef = do mtid <- readIORef tidRef case mtid of- Nothing -> return ()+ Nothing -> return () Just tid -> killThread tid -add :: ReaperSettings workload item- -> IORef (State workload) -> IORef (Maybe ThreadId)- -> item -> IO ()+add+ :: ReaperSettings workload item+ -> IORef (State workload)+ -> IORef (Maybe ThreadId)+ -> item+ -> IO () add settings@ReaperSettings{..} stateRef tidRef item = mask_ $ do- next <- atomicModifyIORef' stateRef cons- next+ next <- atomicModifyIORef' stateRef cons+ next where- cons NoReaper = let wl = reaperCons item reaperEmpty- in (Workload wl, spawn settings stateRef tidRef)- cons (Workload wl) = let wl' = reaperCons item wl- in (Workload wl', return ())+ cons NoReaper =+ let wl = reaperCons item reaperEmpty+ in (Workload wl, spawn settings stateRef tidRef)+ cons (Workload wl) =+ let wl' = reaperCons item wl+ in (Workload wl', return ()) -spawn :: ReaperSettings workload item- -> IORef (State workload) -> IORef (Maybe ThreadId)- -> IO ()+spawn+ :: ReaperSettings workload item+ -> IORef (State workload)+ -> IORef (Maybe ThreadId)+ -> IO () spawn settings stateRef tidRef = do tid <- forkIO $ reaper settings stateRef tidRef+ labelThread tid $ reaperThreadName settings writeIORef tidRef $ Just tid -reaper :: ReaperSettings workload item- -> IORef (State workload) -> IORef (Maybe ThreadId)- -> IO ()+reaper+ :: ReaperSettings workload item+ -> IORef (State workload)+ -> IORef (Maybe ThreadId)+ -> IO () reaper settings@ReaperSettings{..} stateRef tidRef = do threadDelay reaperDelay -- Getting the current jobs. Push an empty job to the reference.@@ -178,18 +206,22 @@ !merge <- reaperAction wl -- Merging the left jobs and new jobs. -- If there is no jobs, this thread finishes.- next <- atomicModifyIORef' stateRef (check merge)- next+ cont <- atomicModifyIORef' stateRef (check merge)+ if cont+ then+ reaper settings stateRef tidRef+ else+ writeIORef tidRef Nothing where- swapWithEmpty NoReaper = error "Control.Reaper.reaper: unexpected NoReaper (1)"+ swapWithEmpty NoReaper = error "Control.Reaper.reaper: unexpected NoReaper (1)" swapWithEmpty (Workload wl) = (Workload reaperEmpty, wl) - check _ NoReaper = error "Control.Reaper.reaper: unexpected NoReaper (2)"+ check _ NoReaper = error "Control.Reaper.reaper: unexpected NoReaper (2)" check merge (Workload wl)- -- If there is no job, reaper is terminated.- | reaperNull wl' = (NoReaper, writeIORef tidRef Nothing)- -- If there are jobs, carry them out.- | otherwise = (Workload wl', reaper settings stateRef tidRef)+ -- If there is no job, reaper is terminated.+ | reaperNull wl' = (NoReaper, False)+ -- If there are jobs, carry them out.+ | otherwise = (Workload wl', True) where wl' = merge wl @@ -199,23 +231,37 @@ -- expired. -- -- @since 0.1.1-mkListAction :: (item -> IO (Maybe item'))- -> [item]- -> IO ([item'] -> [item'])+mkListAction+ :: (item -> IO (Maybe item'))+ -> [item]+ -> IO ([item'] -> [item']) mkListAction f = go id where go !front [] = return front- go !front (x:xs) = do+ go !front (x : xs) = do my <- f x let front' = case my of Nothing -> front- Just y -> front . (y:)+ Just y -> front . (y :) go front' xs -- $example1 -- In this example code, we use a 'Data.Map.Strict.Map' to cache fibonacci numbers, and a 'Reaper' to prune the cache.+--+-- NOTE: When using this module as a cache you should keep in mind that while+-- the reaper thread is active running your "reaperAction", the cache will+-- appear empty to concurrently running threads. Any newly created cache+-- entries will be on the temporary worklist, and will merged back into the the+-- main cache only once the "reaperAction" completes (together with the portion+-- of the extant worklist that the @cleaner@ callback decided to retain).+--+-- If you're looking for a cache that supports concurrent purging of stale+-- items, but without exposing a transient empty cache during cleanup, this is+-- not the cache implementation you need. This module was primarily designed+-- for cleaning up /stuck/ processes, or idle threads in a thread pool. The cache+-- use-case was not a primary design focus. -- -- The @main@ function first creates a 'Reaper', with fields to initialize the -- cache ('reaperEmpty'), add items to it ('reaperCons'), and prune it ('reaperAction').
+ Control/Reaper/Internal.hs view
@@ -0,0 +1,28 @@+module Control.Reaper.Internal (Reaper (..)) where++-- | A data structure to hold reaper APIs.+data Reaper workload item = Reaper+ { reaperAdd :: item -> IO ()+ -- ^ Adding an item to the workload+ , reaperRead :: IO workload+ -- ^ Reading workload.+ , reaperModify :: (workload -> workload) -> IO workload+ -- ^ Modify the workload. The resulting workload is returned.+ --+ -- If there is no reaper thread, the modifier will not be applied and+ -- 'reaperEmpty' will be returned.+ --+ -- If the reaper is currently executing jobs, those jobs will not be in+ -- the given workload and the workload might appear empty.+ --+ -- If all jobs are removed by the modifier, the reaper thread will not be+ -- killed. The reaper thread will only terminate if 'reaperKill' is called+ -- or the result of 'reaperAction' satisfies 'reaperNull'.+ --+ -- @since 0.2.0+ , reaperStop :: IO workload+ -- ^ Stopping the reaper thread if exists.+ -- The current workload is returned.+ , reaperKill :: IO ()+ -- ^ Killing the reaper thread immediately if exists.+ }
Setup.hs view
@@ -1,2 +1,3 @@ import Distribution.Simple+ main = defaultMain
auto-update.cabal view
@@ -1,5 +1,5 @@ name: auto-update-version: 0.1.6+version: 0.2.6 synopsis: Efficiently run periodic, on-demand actions description: API docs and the README are available at <http://www.stackage.org/package/auto-update>. homepage: https://github.com/yesodweb/wai@@ -19,8 +19,14 @@ Control.Debounce Control.Debounce.Internal Control.Reaper- other-modules: Control.AutoUpdate.Util- build-depends: base >= 4 && < 5+ Control.Reaper.Internal+ other-modules: Control.AutoUpdate.Types+ Control.AutoUpdate.Thread+ if !os(windows)+ exposed-modules: Control.AutoUpdate.Internal+ other-modules: Control.AutoUpdate.Event+ build-depends: base >= 4.12 && < 5,+ stm default-language: Haskell2010 if impl(ghc >= 8) default-extensions: Strict StrictData@@ -35,4 +41,5 @@ hs-source-dirs: test type: exitcode-stdio-1.0 build-depends: base, auto-update, exceptions, hspec, retry, HUnit+ build-tool-depends: hspec-discover:hspec-discover default-language: Haskell2010
test/Control/AutoUpdateSpec.hs view
@@ -1,35 +1,37 @@ module Control.AutoUpdateSpec (spec) where -import Control.AutoUpdate-import Control.Concurrent (threadDelay)-import Control.Monad (replicateM_, forM_)-import Data.IORef+-- import Control.AutoUpdate+-- import Control.Concurrent (threadDelay)+-- import Control.Monad (replicateM_, forM_)+-- import Data.IORef import Test.Hspec-import Test.Hspec.QuickCheck +-- import Test.Hspec.QuickCheck+ spec :: Spec spec = return ()- -- do- -- prop "incrementer" $ \st' -> do- -- let st = abs st' `mod` 10000- -- ref <- newIORef 0- -- next <- mkAutoUpdate defaultUpdateSettings- -- { updateAction = atomicModifyIORef ref $ \i ->- -- let i' = succ i in i' `seq` (i', i')- -- , updateSpawnThreshold = st- -- , updateFreq = 10000- -- } - -- forM_ [1..st + 1] $ \i -> do- -- j <- next- -- j `shouldBe` i+-- do+-- prop "incrementer" $ \st' -> do+-- let st = abs st' `mod` 10000+-- ref <- newIORef 0+-- next <- mkAutoUpdate defaultUpdateSettings+-- { updateAction = atomicModifyIORef ref $ \i ->+-- let i' = succ i in i' `seq` (i', i')+-- , updateSpawnThreshold = st+-- , updateFreq = 10000+-- } - -- replicateM_ 50 $ do- -- i <- next- -- i `shouldBe` st + 2+-- forM_ [1..st + 1] $ \i -> do+-- j <- next+-- j `shouldBe` i - -- threadDelay 60000- -- last1 <- readIORef ref- -- threadDelay 20000- -- last2 <- readIORef ref- -- last2 `shouldBe` last1+-- replicateM_ 50 $ do+-- i <- next+-- i `shouldBe` st + 2++-- threadDelay 60000+-- last1 <- readIORef ref+-- threadDelay 20000+-- last2 <- readIORef ref+-- last2 `shouldBe` last1
test/Control/DebounceSpec.hs view
@@ -1,36 +1,54 @@-{-# LANGUAGE ScopedTypeVariables #-}-module Control.DebounceSpec (spec) where+{-# LANGUAGE NumericUnderscores #-}+module Control.DebounceSpec (main, spec) where -import Control.Concurrent-import Control.Debounce+import Control.Concurrent (+ MVar,+ newEmptyMVar,+ takeMVar,+ putMVar,+ newMVar,+ threadDelay,+ tryReadMVar,+ )+import Control.Debounce (+ DebounceSettings(..),+ leadingEdge,+ leadingMuteEdge,+ trailingEdge,+ trailingDelayEdge,+ defaultDebounceSettings,+ ) import qualified Control.Debounce.Internal as DI-import Control.Monad+import Control.Monad (void) import Control.Monad.Catch-import Control.Retry-import Data.IORef-import Test.HUnit.Lang-import Test.Hspec+import Control.Retry (recovering, constantDelay, limitRetries)+import Data.IORef (IORef, readIORef, newIORef, modifyIORef)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTime)+import Test.Hspec (Spec, describe, it, shouldReturn, hspec)+import Test.HUnit (assertBool)+import Test.HUnit.Lang (HUnitFailure (HUnitFailure)) spec :: Spec spec = describe "mkDebounce" $ do describe "Leading edge" $ do it "works for a single event" $ do- (ref, debounced, baton, returnFromWait) <- getDebounce leadingEdge+ (ref, debounced, _baton, returnFromWait) <- getDebounce leadingEdge debounced- waitUntil 5 $ readIORef ref >>= (`shouldBe` 1)+ waitUntil 5 $ readIORef ref `shouldReturn` 1 returnFromWait pause- readIORef ref >>= (`shouldBe` 1)+ readIORef ref `shouldReturn` 1 -- Try another round debounced- waitUntil 5 $ readIORef ref >>= (`shouldBe` 2)+ waitUntil 5 $ readIORef ref `shouldReturn` 2 returnFromWait pause- readIORef ref >>= (`shouldBe` 2)+ readIORef ref `shouldReturn` 2 it "works for multiple events" $ do (ref, debounced, baton, returnFromWait) <- getDebounce leadingEdge@@ -39,30 +57,63 @@ waitForBatonToBeTaken baton debounced pause- waitUntil 5 $ readIORef ref >>= (`shouldBe` 1)+ waitUntil 5 $ readIORef ref `shouldReturn` 1 returnFromWait pause- readIORef ref >>= (`shouldBe` 2)+ readIORef ref `shouldReturn` 2+ describe "LeadingMute edge" $ do+ it "works for a single event" $ do+ (ref, debounced, _baton, returnFromWait) <- getDebounce leadingMuteEdge + debounced+ waitUntil 5 $ readIORef ref `shouldReturn` 1++ returnFromWait+ pause+ readIORef ref `shouldReturn` 1++ -- Try another round+ debounced+ waitUntil 5 $ readIORef ref `shouldReturn` 2++ returnFromWait+ pause+ readIORef ref `shouldReturn` 2++ it "works for multiple events" $ do+ (ref, debounced, baton, returnFromWait) <- getDebounce leadingMuteEdge++ debounced+ waitForBatonToBeTaken baton+ debounced+ pause+ debounced+ waitUntil 5 $ readIORef ref `shouldReturn` 1+ debounced++ returnFromWait+ pause+ readIORef ref `shouldReturn` 1+ describe "Trailing edge" $ do it "works for a single event" $ do- (ref, debounced, baton, returnFromWait) <- getDebounce trailingEdge+ (ref, debounced, _baton, returnFromWait) <- getDebounce trailingEdge debounced pause- waitUntil 5 $ readIORef ref >>= (`shouldBe` 0)+ readIORef ref `shouldReturn` 0 returnFromWait- waitUntil 5 $ readIORef ref >>= (`shouldBe` 1)+ waitUntil 5 $ readIORef ref `shouldReturn` 1 -- Try another round debounced pause- waitUntil 5 $ readIORef ref >>= (`shouldBe` 1)+ waitUntil 5 $ readIORef ref `shouldReturn` 1 returnFromWait- waitUntil 5 $ readIORef ref >>= (`shouldBe` 2)+ waitUntil 5 $ readIORef ref `shouldReturn` 2 it "works for multiple events" $ do (ref, debounced, baton, returnFromWait) <- getDebounce trailingEdge@@ -71,12 +122,54 @@ waitForBatonToBeTaken baton debounced pause- waitUntil 5 $ readIORef ref >>= (`shouldBe` 0)+ readIORef ref `shouldReturn` 0 returnFromWait- waitUntil 5 $ readIORef ref >>= (`shouldBe` 1)+ waitUntil 5 $ readIORef ref `shouldReturn` 1 + describe "TrailingDelay edge" $ do+ it "works for a single event" $ do+ (ref, debounced, _baton, _returnFromWait) <- getDebounce' True trailingDelayEdge + debounced+ readIORef ref `shouldReturn` 0++ waitUntil 1 $ readIORef ref `shouldReturn` 1++ -- Try another round+ debounced+ readIORef ref `shouldReturn` 1++ waitUntil 1 $ readIORef ref `shouldReturn` 2++ it "works for multiple events" $ do+ (ref, debounced, _baton, _returnFromWait) <- getDebounce' True trailingDelayEdge++ start <- getMonotonicTime++ debounced+ readIORef ref `shouldReturn` 0+ -- Asserts at end check that this timing gets added to the cooldown time+ threadDelay 500_000++ readIORef ref `shouldReturn` 0+ before2nd <- getMonotonicTime+ debounced+ readIORef ref `shouldReturn` 0+ threadDelay 500_000++ readIORef ref `shouldReturn` 0+ threadDelay 250_000++ readIORef ref `shouldReturn` 0++ waitUntil 1 $ readIORef ref `shouldReturn` 1+ end <- getMonotonicTime+ assertBool "Took less than 1 sec after retrigger" $+ end - before2nd > 1+ assertBool "Took less than 1.5 sec total" $+ end - start > 1.5+ -- | Make a controllable delay function getWaitAction :: IO (p -> IO (), IO ()) getWaitAction = do@@ -85,36 +178,49 @@ let returnFromWait = putMVar waitVar () return (waitAction, returnFromWait) --- | Get a debounce system with access to the internals for testing getDebounce :: DI.DebounceEdge -> IO (IORef Int, IO (), MVar (), IO ())-getDebounce edge = do- ref :: IORef Int <- newIORef 0- let action = modifyIORef ref (+ 1)+getDebounce = getDebounce' False - (waitAction, returnFromWait) <- getWaitAction+-- | Get a debounce system with access to the internals for testing+getDebounce' :: Bool -> DI.DebounceEdge -> IO (IORef Int, IO (), MVar (), IO ())+getDebounce' useThreadDelay edge = do+ ref <- newIORef 0+ let action = modifyIORef ref (+ 1) - baton <- newEmptyMVar+ (waitAction, returnFromWait) <-+ if useThreadDelay+ then pure (threadDelay, pure ())+ else getWaitAction - debounced <- DI.mkDebounceInternal baton waitAction defaultDebounceSettings {- debounceFreq = 5000000 -- unused- , debounceAction = action- , debounceEdge = edge- }+ baton <- newMVar () - return (ref, debounced, baton, returnFromWait)+ debounced <-+ DI.mkDebounceInternal+ baton+ waitAction+ defaultDebounceSettings+ { debounceFreq = 1_000_000 -- !!! used in 'TrailingDelay' test+ , debounceAction = action+ , debounceEdge = edge+ } + return (ref, debounced, baton, returnFromWait)+ -- | Pause briefly (100ms) pause :: IO ()-pause = threadDelay 100000+pause = threadDelay 100_000 waitForBatonToBeTaken :: MVar () -> IO ()-waitForBatonToBeTaken baton = waitUntil 5 $ tryReadMVar baton >>= (`shouldBe` Nothing)+waitForBatonToBeTaken baton =+ waitUntil 5 $ tryReadMVar baton `shouldReturn` Nothing -- | Wait up to n seconds for an action to complete without throwing an HUnitFailure waitUntil :: Int -> IO a -> IO ()-waitUntil n action = recovering policy [handler] (\_status -> void action)- where policy = constantDelay 1000 `mappend` limitRetries (n * 1000) -- 1ms * n * 1000 tries = n seconds- handler _status = Handler (\(HUnitFailure {}) -> return True)+waitUntil n action =+ recovering policy [handler] (\_status -> void action)+ where+ policy = constantDelay 1000 `mappend` limitRetries (n * 1000) -- 1ms * n * 1000 tries = n seconds+ handler _status = Handler (\HUnitFailure{} -> return True) main :: IO () main = hspec spec
test/Control/ReaperSpec.hs view
@@ -1,14 +1,15 @@ module Control.ReaperSpec (spec) where -import Control.Concurrent-import Control.Reaper-import Data.IORef+-- import Control.Concurrent+-- import Control.Reaper+-- import Data.IORef import Test.Hspec-import Test.Hspec.QuickCheck +-- import Test.Hspec.QuickCheck spec :: Spec spec = return ()+ -- prop "works" $ \is -> do -- reaper <- mkReaper defaultReaperSettings -- { reaperAction = action