Skip to content

Commit 7a7fff1

Browse files
committed
Add shrink timeout
Add withShrinkTimeoutMicros to allow configuring shrink behavior in terms of a timeout.
1 parent 5e58b38 commit 7a7fff1

7 files changed

Lines changed: 300 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Unreleased
2+
3+
* Add `Hedgehog.withShrinkTimeoutMicros` ([#488][488], [@tbidne][tbidne])
4+
15
## Version 1.7 (2025-09-22)
26

37
* Fix eta-reduction issues for GHC 9.0; add CI and cabal support for GHC 9.0.2 ([#557][557], [@tomjaguarpaw][tomjaguarpaw])
@@ -329,6 +333,8 @@
329333
https://github.com/jchia
330334
[Vekhir]:
331335
https://github.com/Vekhir
336+
[tbidne]:
337+
https://github.com/tbidne
332338
[tmcgilchrist]:
333339
https://github.com/tmcgilchrist
334340

@@ -384,6 +390,8 @@
384390
https://github.com/hedgehogqa/haskell-hedgehog/pull/491
385391
[489]:
386392
https://github.com/hedgehogqa/haskell-hedgehog/pull/489
393+
[488]:
394+
https://github.com/hedgehogqa/haskell-hedgehog/pull/488
387395
[486]:
388396
https://github.com/hedgehogqa/haskell-hedgehog/pull/486
389397
[485]:

hedgehog-example/hedgehog-example.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ library
5353
, Test.Example.Registry
5454
, Test.Example.Resource
5555
, Test.Example.Roundtrip
56+
, Test.Example.Shrink
5657
, Test.Example.STLC
5758

5859
build-depends:
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
{-# LANGUAGE CPP #-}
2+
{-# LANGUAGE ScopedTypeVariables #-}
3+
{-# LANGUAGE TemplateHaskell #-}
4+
5+
module Test.Example.Shrink where
6+
7+
import qualified Control.Concurrent as CC
8+
import Control.Monad (when)
9+
import Control.Monad.IO.Class (MonadIO(..))
10+
import Data.IORef (IORef)
11+
import qualified Data.IORef as IORef
12+
#if MIN_VERSION_base(4,11,0)
13+
import qualified GHC.Clock as Clock
14+
#endif
15+
16+
import Hedgehog
17+
import qualified Hedgehog.Gen as Gen
18+
import qualified Hedgehog.Range as Range
19+
import qualified Hedgehog.Internal.Config as Config
20+
import qualified Hedgehog.Internal.Property as Property
21+
import qualified Hedgehog.Internal.Runner as Runner
22+
import Hedgehog.Internal.Report (FailureReport(..), FailedAnnotation (..))
23+
import Hedgehog.Internal.Report (Report(..), Result(..))
24+
25+
-- No limit fully shrinks (5)
26+
prop_ShrinkNoLimit :: Property
27+
prop_ShrinkNoLimit =
28+
withTests 1 . property $ do
29+
(report, gens) <- checkModProp id
30+
[50, 0, 25, 13, 7, 4, 6, 5] === gens
31+
case reportStatus report of
32+
Failed f -> 5 === failureShrinks f
33+
_ -> failure
34+
35+
-- Shrinks 3 times
36+
prop_ShrinkLimit :: Property
37+
prop_ShrinkLimit =
38+
withTests 1 . property $ do
39+
(report, gens) <- checkModProp (withShrinks 3)
40+
[50, 0, 25, 13, 7] === gens
41+
case reportStatus report of
42+
Failed f -> 3 === failureShrinks f
43+
_ -> failure
44+
45+
-- Timeout of 0 i.e. does not shrink at all
46+
prop_ShrinkTimeoutMicrosZero :: Property
47+
prop_ShrinkTimeoutMicrosZero =
48+
withTests 1 . property $ do
49+
(report, gens) <- checkModProp (withShrinkTimeoutMicros 0)
50+
[50] === gens
51+
case reportStatus report of
52+
Failed f -> 0 === failureShrinks f
53+
_ -> failure
54+
55+
-- Timeout of 1,000,000 microseconds = 1 s. Verifies that we get a
56+
-- "partial" shrink.
57+
--
58+
-- There is tension in the shrinkTime. On the one hand, we want it long enough
59+
-- so that we generate the four values [50, 0, 25, 13] before it gets stuck
60+
-- on 13. We don't want it too long, though, because that makes the test
61+
-- slower.
62+
--
63+
-- Experience shows that values under 1 second would cause an occasional CI
64+
-- failure (the machine would not generate all four values before the timeout).
65+
-- A timeout of 1 second, on the other hand, passed CI with 10,000 tests
66+
-- (and took 7 hours!). Thus we use the 1 second timeout as it seems robust
67+
-- enough to not cause CI failures, and we cap the tests at 1 to keep the
68+
-- running time fast.
69+
prop_ShrinkTimeoutMicros :: Property
70+
prop_ShrinkTimeoutMicros =
71+
withTests 1 . property $ do
72+
-- Test generates [ 50 , 0 , 25 , 13 , 7 , 4 , 6 , 5 ]
73+
-- The 1 s timeout combined with the 10 s delay on 13 means
74+
-- shrinking will get stuck on 13, hence:
75+
-- - only generate [50 , 0 , 25 , 13]
76+
-- - final shrink value is 13
77+
(report, gens) <- checkModPropGen delay (withShrinkTimeoutMicros shrinkTime)
78+
[50 , 0 , 25 , 13] === gens
79+
case reportStatus report of
80+
Failed f -> do
81+
1 === failureShrinks f
82+
case failureAnnotations f of
83+
[ann] -> "25" === failedValue ann
84+
_ -> failure
85+
_ -> failure
86+
where
87+
delay x = when (x == 13) (liftIO $ CC.threadDelay delayTime)
88+
shrinkTime = 1000000 -- 1 sec
89+
-- Does not matter what this is, as long as it is longer than shrinkTime
90+
delayTime = 10000000 -- 10 sec
91+
92+
-- Timeout of 2 seconds. Verifies that withShrinkTimeoutMicros indeed cancels
93+
-- shrinking within the time limit we want.
94+
prop_ShrinkTimeoutMicrosClock :: Property
95+
#if MIN_VERSION_base(4,11,0)
96+
prop_ShrinkTimeoutMicrosClock =
97+
withTests 1 . property $ do
98+
startTime <- liftIO $ Clock.getMonotonicTime
99+
annotateShow startTime
100+
_ <- checkModPropGen delay30s (withShrinkTimeoutMicros 2000000)
101+
endTime <- liftIO $ Clock.getMonotonicTime
102+
annotateShow endTime
103+
let timeElapsed = endTime - startTime
104+
annotateShow timeElapsed
105+
-- should be around 2
106+
diff timeElapsed (>=) 1.5
107+
diff timeElapsed (<=) 2.5
108+
where
109+
delay30s x = when (x == 13) (liftIO $ CC.threadDelay 30000000)
110+
#else
111+
-- Needed because auto test discovery via $$(discover) picks up
112+
-- prop_ShrinkTimeoutMicrosClock name before cpp is evaluated.
113+
prop_ShrinkTimeoutMicrosClock = property (pure ())
114+
#endif
115+
116+
-- Given a property modifier, returns the property's report and generated
117+
-- values.
118+
checkModProp ::
119+
( MonadIO m
120+
, MonadTest m
121+
)
122+
=> -- property modifier
123+
(Property -> Property)
124+
-> m (Report Result, [Int])
125+
checkModProp = checkModPropGen (const (pure ()))
126+
127+
-- checkModProp with function to run on the generated values
128+
checkModPropGen ::
129+
( MonadIO m
130+
, MonadTest m
131+
)
132+
=> -- function to run on generated values
133+
(Int -> PropertyT IO ())
134+
-- property modifier
135+
-> (Property -> Property)
136+
-> m (Report Result, [Int])
137+
checkModPropGen onGen md = do
138+
gensRef <- liftIO $ IORef.newIORef []
139+
report <- checkProp $ modProp onGen gensRef md
140+
gens <- liftIO $ reverse <$> IORef.readIORef gensRef
141+
annotateShow report
142+
annotateShow gens
143+
pure (report, gens)
144+
145+
modProp ::
146+
-- function to run on generated values
147+
(Int -> PropertyT IO ())
148+
-- reference to hold generated values
149+
-> IORef [Int]
150+
-- property modifier
151+
-> (Property -> Property)
152+
-> Property
153+
modProp onGen gensRef md = withTests 1 . md . property $ do
154+
-- [ 50 , 0 , 25 , 13 , 7 , 4 , 6 , 5 ]
155+
x :: Int <- forAll $ Gen.integral (Range.linearFrom 0 50 100)
156+
liftIO $ IORef.modifyIORef' gensRef (x :)
157+
onGen x
158+
diff x (<) 5
159+
160+
checkProp :: MonadIO m => Property -> m (Report Result)
161+
checkProp prop = do
162+
seed <- Config.resolveSeed Nothing
163+
liftIO $ Runner.checkReport
164+
(Property.propertyConfig prop)
165+
0
166+
seed
167+
(Property.propertyTest prop)
168+
(const $ pure ())
169+
170+
tests :: IO Bool
171+
tests = checkParallel $$(discover)

hedgehog-example/test/test.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import qualified Test.Example.References
1010
import qualified Test.Example.Registry
1111
import qualified Test.Example.Resource
1212
import qualified Test.Example.Roundtrip
13+
import qualified Test.Example.Shrink
1314
import qualified Test.Example.STLC
1415

1516
main :: IO ()
@@ -28,6 +29,7 @@ main = do
2829
, Test.Example.Registry.tests
2930
, Test.Example.Resource.tests
3031
, Test.Example.Roundtrip.tests
32+
, Test.Example.Shrink.tests
3133
, Test.Example.STLC.tests
3234
]
3335

hedgehog/src/Hedgehog.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ module Hedgehog (
8080
, withShrinks
8181
, ShrinkLimit
8282

83+
, withShrinkTimeoutMicros
84+
, ShrinkTimeoutMicros
85+
8386
, withRetries
8487
, ShrinkRetries
8588

@@ -188,6 +191,7 @@ import Hedgehog.Internal.Property (Property, PropertyT, PropertyName)
188191
import Hedgehog.Internal.Property (Group(..), GroupName)
189192
import Hedgehog.Internal.Property (Confidence, verifiedTermination, withConfidence)
190193
import Hedgehog.Internal.Property (ShrinkLimit, withShrinks)
194+
import Hedgehog.Internal.Property (ShrinkTimeoutMicros, withShrinkTimeoutMicros)
191195
import Hedgehog.Internal.Property (ShrinkRetries, withRetries)
192196
import Hedgehog.Internal.Property (Skip, withSkip)
193197
import Hedgehog.Internal.Property (Test, TestT, property, test)

hedgehog/src/Hedgehog/Internal/Property.hs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ module Hedgehog.Internal.Property (
3333
, DiscardLimit(..)
3434
, DiscardCount(..)
3535
, ShrinkLimit(..)
36+
, ShrinkTimeoutMicros (..)
3637
, ShrinkCount(..)
3738
, Skip(..)
3839
, ShrinkPath(..)
3940
, ShrinkRetries(..)
4041
, withTests
4142
, withDiscards
4243
, withShrinks
44+
, withShrinkTimeoutMicros
4345
, withRetries
4446
, withSkip
4547
, property
@@ -281,6 +283,7 @@ data PropertyConfig =
281283
PropertyConfig {
282284
propertyDiscardLimit :: !DiscardLimit
283285
, propertyShrinkLimit :: !ShrinkLimit
286+
, propertyShrinkTimeoutMicros :: !(Maybe ShrinkTimeoutMicros)
284287
, propertyShrinkRetries :: !ShrinkRetries
285288
, propertyTerminationCriteria :: !TerminationCriteria
286289

@@ -343,6 +346,19 @@ newtype ShrinkLimit =
343346
ShrinkLimit Int
344347
deriving (Eq, Ord, Show, Num, Enum, Real, Integral, Lift)
345348

349+
-- | The time limit before giving up on shrinking, in microseconds.
350+
--
351+
-- Can be constructed using numeric literals:
352+
--
353+
-- @
354+
-- -- 1_000_000 microseconds == 1 second
355+
-- 1_000_000 :: ShrinkTimeoutMicros
356+
-- @
357+
--
358+
newtype ShrinkTimeoutMicros =
359+
ShrinkTimeoutMicros Int
360+
deriving (Eq, Ord, Show, Num, Enum, Real, Integral, Lift)
361+
346362
-- | The numbers of times a property was able to shrink after a failing test.
347363
--
348364
newtype ShrinkCount =
@@ -1183,6 +1199,8 @@ defaultConfig =
11831199
100
11841200
, propertyShrinkLimit =
11851201
1000
1202+
, propertyShrinkTimeoutMicros =
1203+
Nothing
11861204
, propertyShrinkRetries =
11871205
0
11881206
, propertyTerminationCriteria =
@@ -1267,6 +1285,15 @@ withShrinks :: ShrinkLimit -> Property -> Property
12671285
withShrinks n =
12681286
mapConfig $ \config -> config { propertyShrinkLimit = n }
12691287

1288+
-- | Set the timeout -- in microseconds -- after which the test runner gives
1289+
-- up on shrinking and prints the best counterexample. Note that shrinking
1290+
-- can be cancelled before the timeout if the 'ShrinkLimit' is reached.
1291+
-- See 'withShrinks'.
1292+
--
1293+
withShrinkTimeoutMicros :: ShrinkTimeoutMicros -> Property -> Property
1294+
withShrinkTimeoutMicros n =
1295+
mapConfig $ \config -> config { propertyShrinkTimeoutMicros = Just n }
1296+
12701297
-- | Set the number of times a property will be executed for each shrink before
12711298
-- the test runner gives up and tries a different shrink. See 'ShrinkRetries'
12721299
-- for more information.
@@ -1510,4 +1537,4 @@ collect x =
15101537
--
15111538
-- These functions are exported in case you need them in a pinch, but are not
15121539
-- part of the public API and may change at any time, even as part of a minor
1513-
-- update.
1540+
-- update.

0 commit comments

Comments
 (0)