yamlet-1.0.0.0: tests/Retention.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE MagicHash #-}
{-# LANGUAGE UnboxedTuples #-}
-- Full laziness would float the input out of the action of a check into the
-- list of checks, which keeps it alive.
{-# OPTIONS_GHC -fno-full-laziness #-}
-- | The checks run without tasty. In a test of tasty, a major collection at
-- times kept the input of a decode alive although no value referred to it,
-- also after the value was dropped, and ghc-debug found no path from the
-- roots to the input.
module Main (main) where
import Control.Exception
import Control.Monad
import Data.Functor.Const
import Data.Functor.Identity
import Data.IORef
import Data.IntMap.Strict qualified as IM
import Data.IntSet qualified as IS
import Data.List qualified as L
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as M
import Data.Maybe
import Data.Monoid qualified as Mon
import Data.Ord
import Data.Ratio
import Data.Semigroup qualified as Sem
import Data.Sequence qualified as Seq
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.Array qualified as A
import Data.Text.Internal qualified as T
import Data.Text.Lazy qualified as TL
import Data.Time
import Data.Tree qualified as Tree
import GHC.Exts (mkWeakNoFinalizer#)
import GHC.Generics
import GHC.IO
import GHC.Weak
import System.Exit
import System.IO
import System.Mem
import Yamlet
import Yamlet.Test.Helpers.Thunks
-- | Run the checks, and fail if one of them fails.
main :: IO ()
main = do
failures <- fmap catMaybes . forM checks $ \(Check name act) -> do
result <- act
putStrLn $ name ++ ": " ++ maybe "OK" (const "FAIL") result
pure $ (\msg -> name ++ ": " ++ msg) <$> result
unless (null failures) $ do
mapM_ (hPutStrLn stderr) failures
exitFailure
-- | A check with its name. The action returns the message of a failure.
data Check = Check String (IO (Maybe String))
-- | A decoded value does not keep the input alive, for every instance of the
-- library, and neither do the errors of a failed decode. A text that refers
-- to the input keeps all of it, e.g. a slice of it, or a thunk that would
-- copy a slice.
checks :: [Check]
checks =
[ Check "input without a value" $ do
input <- evaluate (T.copy "a")
weak <- weakArray input
performMajorGC
kept <- isJust <$> deRefWeak weak
pure $ if kept then Just "the check keeps the input alive" else Nothing
, retains @T.Text "Text" "a"
, retains @TL.Text "lazy Text" "a"
, retains @String "String" "a"
, retains @(Maybe T.Text) "Maybe" "a"
, retains @[T.Text] "list" "- a\n- b"
, retains @(NE.NonEmpty T.Text) "NonEmpty" "- a\n- b"
, retains @(Seq.Seq T.Text) "Seq" "- a\n- b"
, retains @(Set.Set T.Text) "Set" "- a\n- b"
, retains @(Tree.Tree T.Text) "Tree" "[a, []]"
, retains @(M.Map T.Text T.Text) "Map" "a: b"
, retains @(M.Map T.Text [T.Text]) "Map of lists" "a: [b]"
, retains @(IM.IntMap T.Text) "IntMap" "1: b"
, retains @IS.IntSet "IntSet" "[1, 2]"
, retains @(T.Text, T.Text) "pair" "[a, b]"
, retains @(T.Text, T.Text, T.Text) "triple" "[a, b, c]"
, retains @(T.Text, T.Text, T.Text, T.Text) "quadruple" "[a, b, c, d]"
, retains @(Either T.Text Int) "Left" "Left: a"
, retains @(Either Int T.Text) "Right" "Right: a"
, retains @[Commented T.Text] "Commented" "- a # c"
, retains @[Located T.Text] "Located" "- a"
, retains @[Identity T.Text] "Identity" "- a"
, retains @[Const T.Text ()] "Const" "- a"
, retains @[Down T.Text] "Down" "- a"
, retains @[Sem.Min T.Text] "Min" "- a"
, retains @[Sem.Max T.Text] "Max" "- a"
, retains @[Sem.First T.Text] "Semigroup First" "- a"
, retains @[Sem.Last T.Text] "Semigroup Last" "- a"
, retains @[Mon.First T.Text] "Monoid First" "- a"
, retains @[Mon.Last T.Text] "Monoid Last" "- a"
, retains @[Sem.Dual T.Text] "Dual" "- a"
, retains @[Sem.Sum Int] "Sum" "- 1"
, retains @[Sem.Product Int] "Product" "- 1"
, retains @[Sem.All] "All" "- true"
, retains @[Sem.Any] "Any" "- true"
, retains @[Ratio Int] "Ratio" "- {numerator: 1, denominator: 2}"
, retains @[()] "unit" "- []"
, retains @[Ordering] "Ordering" "- LT"
, retains @[Day] "Day" "- 2026-01-01"
, retains @[Value] "Value" "- !x {a: !y b}"
, retains @[Node] "Node" "- !x {a: &y b} # c"
, retains @Keys "objectKeys" "a: 1\nb: 2"
, retains @[Choice] "oneOf" "- small"
, retains @[Fields] "field lookups" "- a: x\n b: y\n c: [z]"
, retains @[Mode] "enumeration" "- Development"
, retains @[Endpoint] "record" "- host: a\n tags: [b]"
, retains @[Endpoint] "record with a default" "- tags: [b]"
, retains @[Wrapped] "newtype" "- a"
, retains @[Shape] "tagged record" "- tag: Circle\n label: a"
, retains @[Shape] "tagged constructor without fields" "- tag: Dot"
, retains @[Move] "tagged contents" "- tag: Named\n contents: a"
, retains @[Step] "flat contents" "- tag: Ahead\n name: a"
, retains @[Figure] "single field record" "- Round:\n label: a"
, retains @[Figure] "single field contents" "- Sign: a"
, errorRetains @(M.Map T.Text T.Text) "error with a key in the path" "k: [1]"
, errorRetains @(T.Text, M.Map T.Text T.Text)
"error with an alias in the path"
"- &a k\n- *a : [1]"
, errorRetains @Closed "error with a key in the message" "title: a\nhots: 1"
]
-- | The array of the input is garbage while the value is alive. A weak
-- pointer tells, unlike the size of the heap.
retains :: forall a. FromYaml a => String -> T.Text -> Check
retains name doc = Check name $ do
-- A copy, because the array of a literal is never garbage.
input <- evaluate (T.copy doc)
weak <- weakArray input
case decodeText @a input of
Left errs -> pure (Just (show errs))
Right v -> do
ref <- newIORef v
performMajorGC
kept <- isJust <$> deRefWeak weak
failure <-
if kept
then Just <$> (keptAlive "the value" weak =<< readIORef ref)
else pure Nothing
_ <- evaluate =<< readIORef ref
pure failure
-- | The array of the input is garbage while the errors of a failed decode
-- are alive.
errorRetains :: forall a. FromYaml a => String -> T.Text -> Check
errorRetains name doc = Check name $ do
input <- evaluate (T.copy doc)
weak <- weakArray input
case decodeText @a input of
Left errs -> do
-- An error in weak head normal form has no thunks that keep the input.
mapM_ evaluate errs
ref <- newIORef errs
performMajorGC
kept <- isJust <$> deRefWeak weak
failure <-
if kept
then Just <$> (keptAlive "the errors" weak =<< readIORef ref)
else pure Nothing
_ <- evaluate =<< readIORef ref
pure failure
Right _ -> pure (Just "the decode succeeded")
-- | The message for a value that keeps the input alive, with what tells a
-- leak from the state of the runtime: whether a second collection frees the
-- input while the value is still alive, and the thunks in the value. The
-- failure is rare, so the message has to tell all there is.
keptAlive :: String -> Weak () -> a -> IO String
keptAlive what weak x = do
ts <- thunks x
performMajorGC
still <- isJust <$> deRefWeak weak
_ <- evaluate x
pure $
what
++ " keeps the input alive; after a second collection, the input is "
++ (if still then "still alive" else "gone")
++ "; thunks in the value: "
++ (if null ts then "none" else L.intercalate ", " ts)
-- | A weak pointer to the array of the text. A slice of the text shares the
-- array, so the weak pointer is empty only if no text of the array is alive.
weakArray :: T.Text -> IO (Weak ())
weakArray (T.Text (A.ByteArray arr) _ _) = IO $ \s -> case mkWeakNoFinalizer# arr () s of
(# s', w #) -> (# s', Weak w #)
data Mode = Development | Production
deriving stock (Generic)
deriving anyclass (GenericYamlOptions)
deriving (FromYaml) via GenericYaml Mode
data Endpoint = Endpoint {host :: T.Text, tags :: [T.Text]}
deriving stock (Generic)
deriving (FromYaml) via GenericYaml Endpoint
instance GenericYamlOptions Endpoint where
yamlDefault = Just (Endpoint "localhost" [])
newtype Wrapped = Wrapped T.Text
deriving stock (Generic)
deriving anyclass (GenericYamlOptions)
deriving (FromYaml) via GenericYaml Wrapped
data Shape = Circle {label :: T.Text} | Dot
deriving stock (Generic)
deriving anyclass (GenericYamlOptions)
deriving (FromYaml) via GenericYaml Shape
data Move = Named T.Text | Stop
deriving stock (Generic)
deriving anyclass (GenericYamlOptions)
deriving (FromYaml) via GenericYaml Move
newtype Inner = Inner {name :: T.Text}
deriving stock (Generic)
deriving anyclass (GenericYamlOptions)
deriving (FromYaml) via GenericYaml Inner
data Step = Ahead Inner | Halt
deriving stock (Generic)
deriving (FromYaml) via GenericYaml Step
instance GenericYamlOptions Step where
type SumEncoding Step = TaggedFlat
data Figure = Round {label :: T.Text} | Sign T.Text
deriving stock (Generic)
deriving (FromYaml) via GenericYaml Figure
instance GenericYamlOptions Figure where
type SumEncoding Figure = SingleField
newtype Closed = Closed {title :: T.Text}
deriving stock (Generic)
deriving (FromYaml) via GenericYaml Closed
instance GenericYamlOptions Closed where
yamlOptions = defaultYamlOptions {rejectUnknownFields = True}
newtype Keys = Keys [T.Text]
-- The list is lazy, and its unevaluated rest would keep the object.
instance FromYaml Keys where
parseYaml = withMapping $ \o ->
let keys = objectKeys o in length keys `seq` pure (Keys keys)
newtype Choice = Choice Int
instance FromYaml Choice where
parseYaml = oneOf [("small", Choice 1), ("large", Choice 2)]
data Fields = Fields (Maybe T.Text) T.Text (Maybe [T.Text])
instance FromYaml Fields where
parseYaml = withMapping $ \o ->
Fields
<$> parseFieldMaybe o "a"
<*> parseFieldDefault o "b" "x"
<*> parseFieldIfPresent o "c"