-
Notifications
You must be signed in to change notification settings - Fork 51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
An instance for ExceptT #68
Comments
Huh, that's... interesting. I think it would work. I wouldn't have thought of this, because I have a knee-jerk reaction to not mix explicit error returns and runtime exceptions. There is one potential flaw here. In the contract for I'm definitely not ruling this out, it may be worthwhile. It additionally begs some questions around things like |
Maybe the |
instance (MonadUnliftIO m) => MonadUnliftIO (StateT s m) where
withRunInIO stateToIO =
StateT $ \s -> do
r <- newIORef s
a <- withRunInIO $ \runInIO ->
stateToIO $ \stateAction ->
runInIO $ do
(a, s') <- runStateT stateAction s
writeIORef r s'
pure a
s' <- readIORef r
pure (a, s') Yup! This compiles. |
I'm not sure that it does (EDIT: okay, now on coffee cup #2, realizing that i'm about to elaborate at length on exactly what you said), since the call to I'm trying to think of a scenario where this is a problem. foo :: ExceptT MyErr IO ()
foo = do
-- :: ExceptT e m _
withRunInIO $ \runInIO -> do
-- :: IO a
if foo
then throwIO MyErr -- originally an IO exception
else runInIO $ throwError MyErr -- orginally an ExceptT Left In this code block, we have Most of the time it seems like people want to use foo :: ExceptT IOException IO ()
foo = do
-- we want to catch IOExceptions in Left from opening files
ExceptT $ try $ openFile "some file" WriteMode
-- but we don't want to catch IOException in Left from other calls
liftIO $ ioError $ userError "i should be an unchecked exception lol" If we call this in-line, then we should be able to But if we call it via 🤔 Well, we could use instance (MonadUnliftIO m) => MonadUnliftIO (ExceptT e m) where
withRunInIO exceptToIO = ExceptT $ do
exceptionRef <- newEmptyMVar
a <- withRunInIO $ \runInIO ->
exceptToIO $ \exceptAction -> do
ea <- runInIO $ runExceptT exceptAction
case ea of
Left err -> do
putMVar exceptionRef err
pure undefined -- wow laziness rules
Right a ->
pure a
merr <- tryTakeMVar exceptionRef
case merr of
Nothing ->
pure (Right a)
Just e ->
pure (Left e) EDIT: The |
The analysis makes sense 👍 . I'm not sure how best to proceed from here though. Some possibilities:
I lean towards (4) overall, but what are your thoughts? |
Well, there's the "theory" behind So, is this useful? Yeah! Definitely. Does it point to a kinda bad pattern? Yeah. A big point of the These instances prove that - "We can Makes me want to write corresponding |
That last point is basically what |
No, you have to throw an exception, otherwise you don't get short-circuiting and attempt to continue evaluation when you don't in fact have a value to provide to the second argument of test :: IO ()
test = void . runExceptT $ withRunInIO $ \exceptToIO -> do
x :: Bool <- exceptToIO $ throwError ()
print x results in
So can you simply wrap errors thrown via newtype ViaUnliftIO e = ViaUnliftIO e and catch those? Then an However that still requires And what if the underlying action uses And we haven't even started talking about concurrency. |
Great analysis, thanks! |
The state instance doesn't do what you want. put "written from main thread"
UnliftIO.Async.async $ do
get -- "written from main thread"
put "written from thread 1"
get -- "written from thread 1"
threadDelay 2
threadDelay 1
get -- "written from main thread"
UnliftIO.Async.async $ do
threadDelay 10
get -- "written from main thread"
threadDelay 1
get -- "written from thread 1" |
Thanks for testing it out! You're right, with an opaque computation like |
I do agree that it would be a problem because any distinction of how exception was thrown is lost and newtype InternalException e = InteralException { unInternalException :: e }
deriving (Eq, Show)
instance Exception e => Exception (InternalException e)
instance (MonadUnliftIO m, Exception e) => MonadUnliftIO (ExceptT e m) where
withRunInIO exceptToIO =
withExceptT unInternalException $ ExceptT $ try $
withRunInIO $ \runInIO ->
exceptToIO
(runInIO . (either (throwIO . InteralException) pure <=< runExceptT)) The only other problem I see is that using data FooException = FooException
deriving (Eq, Show)
instance Exception FooException
bar :: IO ()
bar = print =<< runExceptT (action :: ExceptT FooException IO Int)
where
action =
wrapper $ do
liftIO $ print "Starting"
_ <- throwError FooException
liftIO $ print "Impossible"
pure 5
wrapper inner =
catch inner $ \(exc :: FooException) -> 0 <$ liftIO (print $ "WHAT?? " ++ show exc) This will produce the expected output: λ> bar
"Starting"
Left FooException However in example below foo :: IO ()
foo = print =<< runExceptT (action :: ExceptT FooException IO Int)
where
action =
wrapper $ do
liftIO $ print "Starting"
_ <- throwError FooException
liftIO $ print "Impossible"
pure 5
wrapper inner = catchAny inner $ \exc -> 0 <$ liftIO (print $ "Gotcha: " ++ show exc) Therefore instead of the expected λ> foo
"Starting"
"Gotcha: FooException"
Right 0 Yet again, it would be possible to solve this by singling out Not sure about
because regardless of how we adjust |
Can the issue with the State instance be patched using STM Chan (or MVar) instead? instance (MonadUnliftIO m) => MonadUnliftIO (StateT s m) where
withRunInIO stateToIO =
StateT $ \s -> do
r <- newChan
a <- withRunInIO $ \runInIO ->
stateToIO $ \stateAction ->
runInIO $ do
(a, s') <- runStateT stateAction s
writeChan r s'
pure a
s' <- readChan r
pure (a, s') Then running the async example, I get: import RIO
import RIO.State
import UnliftIO
import UnliftIO.Async
run :: RIO App ()
run = do
_ <- flip runStateT "" $ do
put "written from main thread"
_ <- async $ do
(logInfo =<< get) -- "written from main thread"
put "written from thread 1"
logInfo =<< get -- "written from thread 1"
threadDelay 2
threadDelay 1
logInfo =<< get -- "written from thread 1"
_ <- async $ do
threadDelay 10
logInfo =<< get -- "written from thread 1"
threadDelay 1
logInfo =<< get -- "written from thread 1"
pure () |
Strong -1 to adding |
I should admit that most of the arguments are a bit over my head, but here are my two cents... I found this issue while trying (and getting stuck) writing ExceptT instance that I needed to avoid throwing exceptions where otherwise I would have Either. The use case I have is that I needed to convert IO exceptions returned by different libraries into Initially I thought to just throw everything as exception and catch it in one place, but besides the fact that it just felt wrong to throw logical errors as exceptions, it was not possible to reliably determine the source of error - different dependencies were throwing the same exception type. So I tried the opposite approach of converting run time exceptions of dependencies into ExceptT exceptions (and my code wasn't throwing anything - it was just threading possible logical errors via ExceptT). The only other alternatives (all bad) would be to:
So using this instance feels the only correct option, and I don't think that the decision about the instance for ExceptT should depend on the decisions what to do with other instances that were discussed here - these questions seem unrelated. So +1 to add ExceptT and no opinion on other instances. The proposed instance works ok, although maybe there is a way to implement it without throwing and immediately catching IO exceptions (it would also remove the need to derive Exception instance)? @parsonsmatt |
That was my instinct as well until I had a use case to uniformly process (i.e. "mix") both the logical errors and run-time exceptions of dependencies... |
It is possible, but that would undermine otherwise good ergonomics of unliftio Also, considering ExceptT and StateT in the same question seems wrong - the arguments are different. |
The instance from @lehin in #68 (comment) looks to me as if it's using exceptions for controlling program flow. Say what you like about that, but it seems to work for me, the casual user. Could unliftio export a special exception type and handler, specifically for the purpose of writing -- edit: something like this:
newtype UnliftIOControlException e = UnliftIOControlException { unControlException :: e }
deriving Typeable
instance Show (UnliftIOControlException e) where
show _ = "Internal error in MonadUnliftIO instance"
instance Typeable e => Exception (UnliftIOControlException e) |
At first I liked the idea of adding a MonadUnliftIO instance to ExceptT (using IIUC in @parsonsmatt 's implementation the instance would safeguard pure "exceptions" by first throwing them with I don't understand why @lehins 's counterexample #68 (comment) should point to a pathological behaviour : isn't the whole point of Edit : I don't understand all tradeoffs of adding this instance, but I think it has some merit. |
|
The goal of this change is to remove the ExceptT context as it is not compatible with unliftio, see: fpco/unliftio#68 Thus this change uses the MonadThrow instead as demonstrated in haskell-servant/servant#950 The upshot is that the LentilleError are no longer part of then types and they need to be catched implicitely using the MonadCatch.
I've been needing the ExceptT instance whilst uprading some code to a new version of Persistent.
where App is Servant Handler (ExceptT IO under the hood). The final code looks like
You can see the long story on reddit. The point is I still don't know if it safe or not (and I reallyw would like to know) and there is a (not so) easy way to work around it by converting the ExceptT to Either; so not adding the instance doesnt' stop people to do it externally. Finally, if it not safe, why and what is the way correct way to |
@maxigit, the way you have done it is safe. The way proposed at the top of this discussion is a quite different way. |
@tomjaguarpaw Then can my way be used to write the ExceptT instance ? |
That depends on whether you want transactions to be rolled back on If you want runDB action = do
pool <- asks sqlPool
flip runSqlPool pool $ coerce action >>= \case
Left err -> do
transactionUndo
pure (Left err)
Right a -> pure a If you want I would strongly recommend just using regular exceptions in toExceptT :: (Exception e) => IO a -> ExceptT e IO a
toExceptT = ExceptT . try
fromExceptT :: (Exception e) => ExceptT e IO a -> IO a
fromExceptT = either throwIO pure . runExceptT There is no benefit to a type like |
No, it can't. Your way isn't sufficient to write a But @parsonsmatt makes an important point that eluded me: your way doesn't roll back the transaction on |
That's a really good point. I just want the old behavior which I assume would rollback transaction on error, which is what you sugested. |
I always assumed it would rollback transaction but haven't actually checked or even experienced it. I might try to revert to the all code and test it. |
@parsonsmatt |
Another variation of the same "IORef smuggling" hack from @parsonsmatt, now with -- | HACK: UnliftIO doesn't legally allow the base @m@ to store mutable state.
-- But see https://github.com/fpco/unliftio/issues/68
instance MonadUnliftIO m => MonadUnliftIO (WriterT w m) where
withRunInIO writerToIO = WriterT $ do
leak <- newIORef []
out <- withRunInIO $ \runInIO ->
writerToIO $ \writerAction -> do
(ret, items) <- runInIO $ runWriterT writerAction
modifyIORef leak (items:) -- this do block is reentrant
pure ret
chunks <- readIORef leak
pure (out, concat (reverse chunks)) Mandatory warning:
I hadn't rigorously checked this — it doesn't matter in my particular use-case, with bounded number of small writes into the Writer — but I'm readily convinced that it's true. Exercise caution to avoid use in full generality. |
This is probably Bad for reasons I haven't figured out yet, but it works.
The text was updated successfully, but these errors were encountered: