timeout-snooze
This package provides a Timeout
that can be snoozed, allowing you to give extra time to the action.
The primary purpose of this package is to support a timeout for hspec
tests that can be reset during flaky test detection (where we rerun a test case, and if it succeeds the second time, we call it a flake).
When we initially implemented flaky test detection, we simply doubled our timeout, but this is unnecessarily lax, and makes true problems take twice as long to be detected.
The system is based on the stm-delay
package, which leverages the GHC event manager API.
This package incurs a single thread overhead for race
for the timeout.
Comparison with Existing Implementations
System.Timeout
This module lives in base
and gives you an efficient function:
timeout :: Int -> IO a -> IO (Maybe a)
However, it is not possible to extend the timeout.
This implementation is used in warp to provide slow loris protection.
This is a bit heavier duty.
Instead of forking a thread for each action, a Manager
is used to store a list of timeout actions.
The Manager
thread wakes every N
microseconds, looks through the list of actions, and toggles them to Inactive
if they are Active
.
The next N
microseconds, if an action is still Inactive
, then it is canceled.
The Handle
can be tickle
d to reset the state to Active
, or pause
can be used to pause the time.
However, the actual time delay of the action is set to the Manager
, which means that different tests cannot have different timeouts.
We have some known long running tests, and so we need configurable timeouts in our implementation.
For this reason, time-manager
is not suitable.
TimerWheel
allows us to create timers and is efficiently designed.
Timers can be set arbitrarily far in the future, so we do get customizable timeouts.
However, there doesn't appear to be a way to reset the timer, so this does not satisfy our needs.
Additionally, it relies on a ki
library which has an opinionated notion of how concurrency is done.
The assumptions made in ki
are invalid in hspec
, which renders it useless to me.
The package async-timer
allows for customizable TimeoutConf
, and the given Timer
can be reset.
The actual timer loop is implemented using Control.Concurrent.Async.race
.
I believe this could be used for my purpose.
We would write:
timeoutKillThread :: Int -> (IO () -> IO a) -> IO (Maybe a)
timeoutKillThread micros action = do
let conf = setInterval micros defaultConf
withAsyncTimer conf \timer -> do
ea <- race (wait timer) (action (reset timer))
case ea of
Left e -> pure Nothing
Right a -> pure (Just a)
Now, this is a bit unsatisfying to me.
I don't think I am so performance sensitive here that I want to go the time-manager
approach with a global registered reaper thread instead of N
reaper threads - the complexity there is challenging, particularly since extending that design with custom timeouts would be tricky.
But this implementation here requires us to fork many threads:
withAsyncTimer
forks a thread for timerLoop
in a withAsync
- We fork a thread with
race
for wait timer
timerLoop
does race
, forking an additional thread for the sleep.
That's 3N
extra threads.
That's quite a lot of overhead.
This package uses the GHC event manager, which makes it the most efficient option: no threads are forked for the timer, just a registered action.
My primary reservation with the library is age.
It was initially written in 2012, updated in 2014, but it did receive a patch in 2024.
This allows us to write:
timeoutKillThread :: Int -> (IO () -> IO a) -> IO (Maybe a)
timeoutKillThread micros action = do
delay <- newDelay micros
let bump = updateDelay delay micros
ea <- race (atomically (waitDelay delay)) (action bump)
case ea of
Left () -> pure Nothing
Right a -> pure (Just a)
We incur an extra thread for race
.
We could avoid that, but it would essentially require us re-implementing the stm-delay
but instead of writeTVar
we'd be doing killThread
- which the docs for TimeoutCallback
explicitly warn against.
I'm pretty pleased with a single thread overhead.