Exceptions in continuation-based monads

May 30, 2014

GravatarMichael Snoyman

I've been meaning to write a blog post for a few weeks now about exception handling in Haskell, especially with regards to monad transformer stacks. Unfortunately, this is not that blog post, just one small aspect of it: exception handling in continuation-base monads.

I've seen many people at different times advocate having some kind of exception handling in continuation-based monads. Without calling out individual instances, I'll sum up a lot of what I've discussed as, "Why can't conduit/enumerator/pipes have a bracket function?"

After noticing yet another request for such a thing this morning, I decided to write up a quick demonstration of what happens when you create a bracket function for the enumerator package. I'll be using MonadCatchIO-transformers (which is thankfully deprecated in favor of the exceptions package), and snap-core's orphan instance.

Let's start off by noticing something interesting: enumerator provides an enumFile function (for reading the contents of a file), but no iterFile equivalent to write data back. Using a bracket, it's actually really easy to write up such a function (including some debug output to make sure we're being safe):

iterFile :: (MonadCatchIO m, MonadIO m, Functor m)
         => FilePath -> Iteratee ByteString m ()
iterFile fp = bracket
    (liftIO $ do
        putStrLn $ "opening file for writing: " ++ fp
        IO.openFile fp IO.WriteMode)
    (\h -> liftIO $ do
        putStrLn $ "closing file for writing: " ++ fp
        IO.hClose h)
    iterHandle

There shouldn't be any surprises in this implementation: we open a file handle in the acquire argument, close that handle in the release argument, and then use the handle in the inner argument. All is well in the world. Now let's try actually using this function, both with and without exceptions being thrown:

main :: IO ()
main = do
    writeFile "exists.txt" "this file exists"
    run (enumFile "exists.txt" $$ iterFile "output1.txt") >>= print
    run (enumFile "does-not-exist.txt" $$ iterFile "output2.txt") >>= print

Or you can try running the code yourself. Let's look at the output:

opening file for writing: output1.txt
closing file for writing: output1.txt
Right ()
opening file for writing: output2.txt
Left does-not-exist.txt: openBinaryFile: does not exist (No such file or directory)

Notice that the output2.txt Handle is never closed. This is inherent to working with any continuation based monad, since there are no guarantees that the continuation will be called at all. It's also impossible to know if your continuation will be called only once. With something like ContT, it's possible to have the continuation run multiple times, in which case your cleanup actions can run multiple times, which can be really bad.

The exceptions package handles this in the right way. There are two different type classes: MonadCatch allows for catching exceptions (which a continuation based monad does allow for), whereas MonadMask gives guarantees about bracket/finally semantics, which is what a continuation-based monad cannot do. Another valid approach is monad-control, which makes it (I believe) impossible to write invalid instances.

(I want to get into more of the details of the trade-offs between exceptions and monad-control, but that will have to wait for another blog post. For now, I just wanted to address immediate continuation based concern.)

If you're in a continuation based monad and you need exception safe resource handling, there is a solution: resourcet. resourcet hoists the exception safety outside of the realm of the continuation based code, and maintainers finalizer functions via mutable variables. Note that this isn't just useful for continuation based monads, but for any situation where you don't have full control over the flow of execution of the program. For example, you'd use the same technique for an io-streams directory traversal.

One last caveat. There is one case where a continuation based monad could in theory have a valid bracket function, which is where you have full knowledge of the code which will run the continuation, and can guarantee that all continuations will always be executed. So if you hide constructors and only expose such run functions, you might be safe. But the burden of proof is on you.


Note: I'm purposely not linking to any of the conversations I've referred to about getting a bracket function for continuation based monads, I don't feel like calling people out here. Also, in case you don't feel like loading up the School of Haskell page, here's the full source code for my example above:

{-# LANGUAGE PackageImports #-}
import           "MonadCatchIO-transformers" Control.Monad.CatchIO  (MonadCatchIO,
                                                                     bracket)
import           Control.Monad.IO.Class (MonadIO, liftIO)
import           Data.ByteString        (ByteString, hPut)
import           Data.Enumerator        (Iteratee, run, ($$))
import           Data.Enumerator.Binary (enumFile, iterHandle)
import           Snap.Iteratee          () -- orphan instance
import qualified System.IO              as IO

iterFile :: (MonadCatchIO m, MonadIO m, Functor m)
         => FilePath -> Iteratee ByteString m ()
iterFile fp = bracket
    (liftIO $ do
        putStrLn $ "opening file for writing: " ++ fp
        IO.openFile fp IO.WriteMode)
    (\h -> liftIO $ do
        putStrLn $ "closing file for writing: " ++ fp
        IO.hClose h)
    iterHandle

main :: IO ()
main = do
    writeFile "exists.txt" "this file exists"
    run (enumFile "exists.txt" $$ iterFile "output1.txt") >>= print
    run (enumFile "does-not-exist.txt" $$ iterFile "output2.txt") >>= print

Comments

comments powered by Disqus

Archives