Skip to content

Commit ce080af

Browse files
committed
io-sim: exception handling in traceResult
1 parent ef82ae8 commit ce080af

File tree

3 files changed

+63
-2
lines changed

3 files changed

+63
-2
lines changed

io-sim/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
* Added support for unique symbol generation à la `Data.Unique`.
1010
* Removed a misleading internal comment.
11+
* Fixed error handling in `traceResult` so one can combine it (or ather APIs
12+
which are based on it: `runSim`, `runSimOrThrow`, or `runSimStrictShutdown`)
13+
with `within` or `discardAfter` from `QuickCheck`. See the test suite how to
14+
use `discardAfter` with `IOSim`.
1115

1216
## 1.8.0.1
1317

io-sim/src/Control/Monad/IOSim.hs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ import Data.Typeable (Typeable)
9999

100100
import Data.List.Trace (Trace (..))
101101

102-
import Control.Exception (throw)
102+
import Control.Exception (SomeAsyncException (..), throw)
103103

104104
import Control.Monad.ST.Lazy
105105

@@ -422,7 +422,16 @@ traceResult strict = unsafePerformIO . eval
422422
where
423423
eval :: SimTrace a -> IO (Either Failure a)
424424
eval a = do
425-
r <- try (evaluate a)
425+
-- NOTE: It's fine to let asynchronous exceptions pass through. The only
426+
-- way simulation could raise them is by using `throw` in pure code, while
427+
-- `throwIO` in the simulation will be captured as `FailureException`. So
428+
-- we can safely assume asynchronous exceptions are coming from the
429+
-- environment running the simulation, e.g. `QuickCheck`, as in the case
430+
-- of `within` or `discardAfter` operators.
431+
r <- tryJust (\e -> case fromException @SomeAsyncException e of
432+
Just _ -> Nothing
433+
Nothing -> Just e)
434+
(evaluate a)
426435
case r of
427436
Left e -> return (Left (FailureEvaluation e))
428437
Right _ -> go a

io-sim/test/Test/Control/Monad/IOSim.hs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{-# LANGUAGE CPP #-}
22

33
{-# OPTIONS_GHC -Wno-orphans #-}
4+
-- `-fno-full-laziness` is needed for `discardAfter` to work correctly, see
5+
-- `unit_discardAfter` below.
6+
{-# OPTIONS_GHC -fno-full-laziness #-}
47

58
module Test.Control.Monad.IOSim
69
( tests
@@ -43,6 +46,7 @@ import Test.Control.Monad.STM
4346
import Test.Control.Monad.Utils
4447

4548
import Test.QuickCheck
49+
import Test.QuickCheck.Property as QC
4650
import Test.Tasty hiding (after)
4751
import Test.Tasty.QuickCheck
4852

@@ -67,6 +71,10 @@ tests =
6771
, testProperty "{register,thread}Delay" unit_registerDelay_threadDelay
6872
, testProperty "throwTo and STM" unit_throwTo_and_stm
6973
]
74+
, testGroup "QuickCheck"
75+
[ testProperty "timeout: discardAfter" unit_discardAfter
76+
, testProperty "timeout: within" unit_within
77+
]
7078
, testProperty "threadId order (IOSim)" (withMaxSuccess 1000 prop_threadId_order_order_Sim)
7179
, testProperty "forkIO order (IOSim)" (withMaxSuccess 1000 prop_fork_order_ST)
7280
, testProperty "order (IO)" (expectFailure prop_fork_order_IO)
@@ -1073,6 +1081,46 @@ prop_stacked_timeouts timeout0 timeout1 actionDuration =
10731081
= Just Nothing
10741082

10751083

1084+
-- | Check that `discardAfter` works as expected.
1085+
--
1086+
-- NOTE: using `discardAfter` with `IOSim` is more tricky than for `IO`
1087+
-- properties, since `IOSim` is a pure computation. One need to wrap the
1088+
-- simulation in a lambda and use `-fno-full-laziness` to avoid GHC from
1089+
-- moving the thunk outside of the lambda, and evaluating it just once.
1090+
--
1091+
unit_discardAfter :: Property
1092+
unit_discardAfter = mapTotalResult f
1093+
. discardAfter 10
1094+
$ \() -> runSimOrThrow $ True <$ (forever (threadDelay 10))
1095+
where
1096+
-- if `discard` kills the computation with the `Timeout` exception,
1097+
-- `theException` is `Nothing`, but if `traceResult` wraps it, then it is
1098+
-- a `Just`. We mark each test a success if `theException` is `Nothing`,
1099+
-- otherwise the test would fail with too many discarded cases, but if we re
1100+
-- introduce the bug in `traceResult` then it fails, since then
1101+
-- `theException` is a `Just`.
1102+
f :: QC.Result -> QC.Result
1103+
f r@MkResult { QC.theException = Nothing }
1104+
= r { ok = Just True }
1105+
f r = r
1106+
1107+
1108+
-- | Check that `within` works as expected.
1109+
--
1110+
unit_within :: Property
1111+
unit_within = mapTotalResult f
1112+
. within 10
1113+
$ runSimOrThrow $ True <$ (forever (threadDelay 10))
1114+
where
1115+
-- if `within` kills the computation with the `Timeout` exception,
1116+
-- `theException` is `Nothing`, but if `traceResult` wraps it, then it is
1117+
-- a `Just`.
1118+
f :: QC.Result -> QC.Result
1119+
f r@MkResult { QC.theException = Nothing }
1120+
= r { expect = False }
1121+
f r = r
1122+
1123+
10761124
unit_timeouts_and_async_exceptions_1 :: Property
10771125
unit_timeouts_and_async_exceptions_1 =
10781126
let trace = runSimTrace experiment in

0 commit comments

Comments
 (0)