Skip to content

liftWith for MockT #32

@ChickenProp

Description

@ChickenProp

I've been porting a codebase from monad-mock where we've been using the MonadTransControl instance. That's not available for HMock. Currently I've got an orphan instance of it that's definitely not safe in general, but that works in the places where I use it. But I think it might be possible to provide a function that's basically liftWith specialized to MockT, that could be used safely.

To illustrate, here's the function in question, and the code I'm testing it with:

import qualified Test.HMock.Internal.State as HMockI
-- And a bunch of other imports

-- | Basically `liftWith`
liftMockWith
  :: forall m a
   . ((forall n b . Coercible m n => MockT n b -> n b) -> m a)
  -> MockT m a
liftMockWith run = HMockI.MockT $ ReaderT $ \state ->
  let runArg :: forall n b . MockT n b -> n b
      runArg =
        let changeState :: HMockI.MockState m -> HMockI.MockState n
            changeState (HMockI.MockState {..}) = HMockI.MockState
              { mockExpectSet       = unsafeCoerce mockExpectSet
              , mockDefaults        = unsafeCoerce mockDefaults
              , mockAllowUnexpected = unsafeCoerce mockAllowUnexpected
              , mockSideEffects     = unsafeCoerce mockSideEffects
              , mockParent          = unsafeCoerce mockParent
              , ..
              }
        in  flip runReaderT (changeState state) . HMockI.unMockT
  in  run runArg

-- Test it:
class Monad m => MonadFilesystem m where
  readF :: FilePath -> m String
  writeF :: FilePath -> String -> m ()

copyFile :: MonadFilesystem m => FilePath -> FilePath -> m ()
copyFile a b = readF a >>= writeF b

makeMockable [t|MonadFilesystem|]

spec :: Spec
spec =
  it "Test mock" $ do
    return () :: IO ()
    runIdentityT $ do
      return () :: IdentityT IO ()
      HMock.runMockT $ do
        return () :: HMock.MockT (IdentityT IO) ()
        expect $ ReadF "foo.txt" |-> "contents"
        expect $ WriteF "bar.txt" "contents" |-> ()
        liftMockWith $ \run -> do
          return () :: IdentityT IO ()
          liftIO $ run $ do
            copyFile "foo.txt" "bar.txt" :: MockT IO ()

I've included some type annotations partly as a hint to the typechecker and partly to make it a bit clearer what's going on.

This works fine, despite the unsafeCoerce. We can also move one or both expect calls to directly above copyFile, inside the run block.

We can use the same basic function to implement an orphan instance of MonadTransControl. (With StT MockT a = a and restoreT = lift.) And then if we replace liftMockWith in that code with liftWith, there's still no problem. (At least not at -O0. There's a lot of edge cases I haven't tested thoroughly yet.)

But it doesn't work if we replace IdentityT with a version of itself that's been implemented with data instead of newtype. That is, instead of importing IdentityT, define

data IdentityT m a = IdentityT { runIdentityT :: m a }
  deriving stock Functor

instance Applicative m => Applicative (IdentityT m) where
  pure = IdentityT . pure
  IdentityT f <*> IdentityT x = IdentityT $ f <*> x

instance Monad m => Monad (IdentityT m) where
  IdentityT x >>= f = IdentityT $ x >>= runIdentityT . f

instance MonadIO m => MonadIO (IdentityT m) where
  liftIO = IdentityT . liftIO

and suddenly we get a segfault.

       uncaught exception: ErrorCall
       Wrong arguments: writeF "bar.txt" "\1099511628032zsh: segmentation fault (core dumped)  cabal run $GHC_LINK_OPTS -O0 testnofork -- -m "Test mock"

Presumably we've gone from doing unsafeCoerce between things that are coercible, to between things that aren't. And if we go back to liftMockWith, using our data-based IdentityT, we get a comile error:

    • Couldn't match representation of type ‘IdentityT IO’
                               with that of ‘IO’
        arising from a use of ‘run’
      The data constructor ‘ghc-prim-0.6.1:GHC.Types.IO’
        of newtype ‘IO’ is not in scope

(For either version of IdentityT, and either liftWith or liftMockWith, we can remove the liftIO and it works. Then the type of the copyFile line is MockT (IdentityT IO) (), and we're coercing between a thing and itself.)

So a big question here is, is that unsafeCoerce actually safe? Ultimately the things we're coercing are all either TVar [Step {m/n}] or TVar (ExpectSet (Step {m/n})). Step has type role nominal, so coerce isn't allowed unless m ~ n, but that doesn't prove it's unsafe. I think the thing I'd be most worried about is it has a Typeable m constraint. But I don't currently know where that comes from or if it's a problem here.

If the unsafeCoerce actually is unsafe, then another option might be to restrict the type further, to m ~ n - at that point it looks similar to withMockT, I'm not sure what the relationship is between them but maybe withMockT already gives us everything we're going to get here.

liftMockWith ::              ((forall a . MockT m a -> m a) -> m b)
withMockT    :: MonadIO m => ((forall a . MockT m a -> m a) -> MockT m b) -> m b

(It's also possible withMockT will be sufficient for what I want to do. I don't currently think so, but I haven't fully explored the possibility. edit: looked into it now, I'm pretty sure it won't.)


Sorry this is kind of rambling. I guess the main questions I'm wondering here are

  1. Does the liftMockWith implementation above seem safe?
  2. Would you consider adding it?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions