Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Leading vs. trailing edges for Control.Debounce #756

Merged
merged 11 commits into from
Jul 9, 2019
5 changes: 5 additions & 0 deletions auto-update/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# ChangeLog for auto-update

## 0.1.6

* Add control of activation on leading vs. trailing edges for Control.Debounce
[#756](https://github.com/yesodweb/wai/pull/756)

## 0.1.5

* Using the Strict and StrictData language extensions for GHC >8.
Expand Down
72 changes: 19 additions & 53 deletions auto-update/Control/Debounce.hs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
-- printString <- 'mkDebounce' 'defaultDebounceSettings'
-- { 'debounceAction' = putStrLn "Running action"
-- , 'debounceFreq' = 5000000 -- 5 seconds
-- , 'debounceEdge' = 'DI.trailingEdge' -- Trigger on the trailing edge
-- }
-- @
--
Expand All @@ -25,70 +26,35 @@
-- @since 0.1.2
module Control.Debounce
( -- * Type
DebounceSettings
DI.DebounceSettings
, defaultDebounceSettings
-- * Accessors
, debounceFreq
, debounceAction
, DI.debounceFreq
, DI.debounceAction
, DI.debounceEdge
, DI.leadingEdge
, DI.trailingEdge
-- * Creation
, mkDebounce
) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar)
import Control.Exception (SomeException, handle, mask_)
import Control.Monad (forever, void)

-- | Settings to control how debouncing should work.
--
-- This should be constructed using 'defaultDebounceSettings' and record
-- update syntax, e.g.:
--
-- @
-- let settings = 'defaultDebounceSettings' { 'debounceAction' = flushLog }
-- @
--
-- @since 0.1.2
data DebounceSettings = DebounceSettings
{ debounceFreq :: Int
-- ^ Microseconds lag required between subsequence calls to the debounced
-- action.
--
-- Default: 1 second (1000000)
--
-- @since 0.1.2
, debounceAction :: IO ()
-- ^ Action to be performed.
--
-- Note: all exceptions thrown by this action will be silently discarded.
--
-- Default: does nothing.
--
-- @since 0.1.2
}
import Control.Concurrent (newEmptyMVar, threadDelay)
import qualified Control.Debounce.Internal as DI

-- | Default value for creating a 'DebounceSettings'.
--
-- @since 0.1.2
defaultDebounceSettings :: DebounceSettings
defaultDebounceSettings = DebounceSettings
{ debounceFreq = 1000000
, debounceAction = return ()
defaultDebounceSettings :: DI.DebounceSettings
defaultDebounceSettings = DI.DebounceSettings
{ DI.debounceFreq = 1000000
, DI.debounceAction = return ()
, DI.debounceEdge = DI.leadingEdge
}

-- | Generate an action which will trigger the debounced action to be
-- performed. The action will either be performed immediately, or after the
-- current cooldown period has expired.
-- | Generate an action which will trigger the debounced action to be performed.
--
-- @since 0.1.2
mkDebounce :: DebounceSettings -> IO (IO ())
mkDebounce (DebounceSettings freq action) = do
baton <- newEmptyMVar
mask_ $ void $ forkIO $ forever $ do
takeMVar baton
ignoreExc action
threadDelay freq
return $ void $ tryPutMVar baton ()

ignoreExc :: IO () -> IO ()
ignoreExc = handle $ \(_ :: SomeException) -> return ()
mkDebounce :: DI.DebounceSettings -> IO (IO ())
mkDebounce settings = do
baton <- newEmptyMVar
DI.mkDebounceInternal baton threadDelay settings
104 changes: 104 additions & 0 deletions auto-update/Control/Debounce/Internal.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{-# LANGUAGE ScopedTypeVariables #-}

-- | Unstable API which exposes internals for testing.
module Control.Debounce.Internal (
thomasjm marked this conversation as resolved.
Show resolved Hide resolved
DebounceSettings(..)
, DebounceEdge(..)
, leadingEdge
, trailingEdge
, 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)

-- | Settings to control how debouncing should work.
--
-- This should be constructed using 'defaultDebounceSettings' and record
-- update syntax, e.g.:
--
-- @
-- let settings = 'defaultDebounceSettings' { 'debounceAction' = flushLog }
-- @
--
-- @since 0.1.2
data DebounceSettings = DebounceSettings
{ debounceFreq :: Int
-- ^ Length of the debounce timeout period in microseconds.
--
-- Default: 1 second (1000000)
--
-- @since 0.1.2
, debounceAction :: IO ()
-- ^ Action to be performed.
--
-- Note: all exceptions thrown by this action will be silently discarded.
--
-- Default: does nothing.
--
-- @since 0.1.2
, debounceEdge :: DebounceEdge
-- ^ Whether to perform the action on the leading edge or trailing edge of
-- the timeout.
--
-- Default: 'trailingEdge'.
--
-- @since 0.1.6
}

-- | Setting to control whether the action happens at the leading and/or trailing
-- edge of the timeout.
--
-- @since 0.1.6
data DebounceEdge =
thomasjm marked this conversation as resolved.
Show resolved Hide resolved
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)


-- | 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.
--
-- @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.
--
-- @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 -> ignoreExc action >> runDelay
thomasjm marked this conversation as resolved.
Show resolved Hide resolved
Trailing -> runDelay

return $ void $ tryPutMVar baton ()

where runDelay = do
delayFn freq

case edge of
Trailing -> do
-- Empty the baton of any other activations during the interval
void $ tryTakeMVar baton
ignoreExc action
Leading ->
-- We already fired at the beginning of the interval so do nothing
return ()

ignoreExc :: IO () -> IO ()
ignoreExc = handle $ \(_ :: SomeException) -> return ()
20 changes: 11 additions & 9 deletions auto-update/auto-update.cabal
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: auto-update
version: 0.1.5
version: 0.1.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
Expand All @@ -17,6 +17,7 @@ library
ghc-options: -Wall
exposed-modules: Control.AutoUpdate
Control.Debounce
Control.Debounce.Internal
Control.Reaper
other-modules: Control.AutoUpdate.Util
build-depends: base >= 4 && < 5
Expand All @@ -26,11 +27,12 @@ library

-- Test suite is currently not robust enough, gives too many false negatives.

-- test-suite spec
-- main-is: Spec.hs
-- other-modules: Control.AutoUpdateSpec
-- Control.ReaperSpec
-- hs-source-dirs: test
-- type: exitcode-stdio-1.0
-- build-depends: base, auto-update, hspec
-- default-language: Haskell2010
test-suite spec
main-is: Spec.hs
other-modules: Control.AutoUpdateSpec
Control.DebounceSpec
Control.ReaperSpec
hs-source-dirs: test
type: exitcode-stdio-1.0
build-depends: base, auto-update, exceptions, hspec, retry, HUnit
default-language: Haskell2010
51 changes: 26 additions & 25 deletions auto-update/test/Control/AutoUpdateSpec.hs
Original file line number Diff line number Diff line change
@@ -1,34 +1,35 @@
module Control.AutoUpdateSpec (spec) where

import Test.Hspec
import Test.Hspec.QuickCheck
import Data.IORef
import Control.AutoUpdate
import Control.Concurrent (threadDelay)
import Control.Monad (replicateM_, forM_)
import Control.AutoUpdate
import Data.IORef
import Test.Hspec
import Test.Hspec.QuickCheck

spec :: Spec
spec = 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
}
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
-- forM_ [1..st + 1] $ \i -> do
-- j <- next
-- j `shouldBe` i

replicateM_ 50 $ do
i <- next
i `shouldBe` st + 2
-- replicateM_ 50 $ do
-- i <- next
-- i `shouldBe` st + 2

threadDelay 60000
last1 <- readIORef ref
threadDelay 20000
last2 <- readIORef ref
last2 `shouldBe` last1
-- threadDelay 60000
-- last1 <- readIORef ref
-- threadDelay 20000
-- last2 <- readIORef ref
-- last2 `shouldBe` last1