Exiting a Haskell process

24 Aug 2017 Michael Snoyman

This blog post was inspired by a recent Stack Overflow question. It also uses the Stack script interpreter for inline snippets if you want to play along at home. Don't forget to get Stack first.

The non trick case

Here's a non trick question: what do you think the output of this series of shell commands is going to be?

$ cat Main.hs
#!/usr/bin/env stack
-- stack --resolver lts-9.0 script
import System.Exit

main = exitWith (ExitFailure 42)
$ stack Main.hs
$ echo $?

If you guessed 42, you're right. Our Haskell process uses exitWith to exit the process with exit code 42. Then echo $? prints the last exit code. All relatively straightforward (if you're familiar with the shell).

Race condition

Alright, let's make it more fun with some concurrency (concurrency makes everything more fun):

#!/usr/bin/env stack
-- stack --resolver lts-9.0 script
import System.Exit
import Control.Concurrent.Async

main = concurrently
  (exitWith (ExitFailure 41))
  (exitWith (ExitFailure 42))

The output this time is nondeterministic. We don't know if the first thread (which exits with 41) or the second thread (which exits with 42) will exit first. I tested this about 5 times on my machine, and got both 41 and 42 as outputs. So this isn't just theoretically nondetministic, it's practically nondetministic.

Surprise! Warp

Alright, that's fine, probably nothing too terribly surprising. Now let's throw the curve balls in. I'm going to write a web server with Warp, and when someone requests /die, I want the server to go down. Here's the code. If you're not familiar with WAI and Warp, just ignore the web bits and focus on the exitWith part:

#!/usr/bin/env stack
-- stack --resolver lts-9.0 script
{-# LANGUAGE OverloadedStrings #-}
import Network.Wai
import Network.Wai.Handler.Warp
import Network.HTTP.Types
import System.Exit

main = run 3000 $ \req send ->
  if pathInfo req == ["die"]
    then exitWith (ExitFailure 43)
    else send (responseLBS status200 [] "Still alive!\n")

Let's see what happens when we run it:

$ stack Main.hs&
[2] 19117
$ curl http://localhost:3000
Still alive!
$ curl http://localhost:3000
Still alive!
$ curl http://localhost:3000
Still alive!
$ curl http://localhost:3000/die
ExitFailure 43
Something went wrong
$ curl http://localhost:3000
Still alive!
$ fg
stack Main.hs
^C

A few different weird things just happened:

  • When we made a request to /die, the server apparently didn't die! We can see that from both the fact that the next request succeeded, and the fg call.
  • For some reason, ExitFailure 43 is printed to the console. We can't tell here, but it's coming from the server process.
  • And our HTTP response body contains the content Something went wrong, even though we didn't write that.

I would have expected the process to just die and get an empty response. Why this surprising behavior instead?

Implementation of exitWith

To understand what's happening, let's look at a simplified version of the implementation of the exitWith function. Feel free to read the original as well.

exitWith :: ExitCode -> IO a
exitWith code = throwIO code

I would have anticipated that this would, you know, actually exit the process. Such a function does exist in Haskell. It's called exitImmediately, it lives in the unix package, and it calls out to the exit C library function. But not exitWith: it throws a runtime exception.

There's a good reason for this exception-based behavior. It allows cleanup code to run before the process just up and dies, which would allow things like flushing file handles and gracefully closing network connections. However, this can certainly result in surprising behavior. We'll get back to the Warp case in a bit; let's see something simpler first:

#!/usr/bin/env stack
-- stack --resolver lts-9.0 script
import Control.Exception.Safe
import System.Exit

main = tryAny foo >>= print

foo :: IO String
foo = exitWith (ExitFailure 44)

And the output is:

$ stack Main.hs
Left (ExitFailure 44)
$ echo $?
0

We've exited with code 0, a success! And our program continued running after the call to exitWith. That's because our tryAny call intercepted the exception, converted it into a Left value, and then our program succeeded in printing out that value.

What's up with Warp?

Warp employs a pretty simple model for handling requests:

  • Grabs a listening port
  • Loops accepting connections on that port
  • For each connection, fork a new worker thread

Within each worker thread, Warp accepts a request, passes it to the user-supplied application, takes the response, and sends it. Additionally, Warp installs an exception handler in case the application throws an exception. In that case, it will print the exception to stderr and send a 500 Internal Server Error response with the response body (wait for it) Something went wrong.

So of course our initial attempt at killing our Warp application failed: the exception was intercepted!

As an aside, if you really want to be able to exit from a Warp application, you can see my answer on Stack Overflow, which I'm not going to detail here as it will be a tangent to the main point.

Child threads in general

Alright, let's make another mistake (certainly my specialty):

#!/usr/bin/env stack
-- stack --resolver lts-9.0 script
import Control.Concurrent
import System.Exit

main = do
  forkIO (exitWith (ExitFailure 45))
  threadDelay 1000000
  putStrLn "Normal exit :("

We're not intercepting the exception via a handler at all, and thanks to our threadDelay (which delays the parent thread by one second), we have plenty of time for the child thread to act before the parent exits on its own. Surely this will exit with exit code 45, right?

$ stack Main.hs
Main.hs: ExitFailure 45
Normal exit :(
$ echo $?
0

Foiled again! We're running into something different now. In GHC's runtime system, a process exits when the main thread exits. If a child thread exits for any reason, the process keeps running. If the main thread exits, even if there are still child threads running, the process exits.

When we call forkIO, a default exception handler is installed on this new child thread. And that default exception handler will simply print out the exception to stderr. That's the Main.hs: ExitFailure 45 output we see.

As usual: async to the rescue

Where did we go wrong? By using the forkIO function, of course! As I'm wont to say:

The problem is that forkIO installs a default exception handler, instead of properly propagating exceptions through our application. Fortunately, there's a great solution to this, which we've already seen in this post: use the concurrently function from async (or, in some cases, race).

#!/usr/bin/env stack
-- stack --resolver lts-9.0 script
import Control.Concurrent
import Control.Concurrent.Async
import System.Exit

main = concurrently (exitWith (ExitFailure 45)) $ do
  threadDelay 1000000
  putStrLn "Normal exit :("

Any luck?

$ stack Main.hs
$ echo $?
45

Woohoo! I've never been so happy to see a process exit with a failure code before.

In contrast to forkIO, the concurrently and race functions track the exceptions occurring in their child threads and rethrow those exceptions in the parent thread should anything go wrong. So instead of exceptions disappearing into the aether, they tear down our process with dignity.

If you're not familiar with the async library, check out the tutorial I wrote on it, which focuses on using concurrently and race wherever possible.

Summary

Takeaways to remember:

  • exitWith works by throwing exceptions, not directly killing the process
  • A Haskell process dies when the main thread dies
  • Warp worker threads install an exception handler that generates 500 Internal Server Error responses
  • Use concurrently and race in place of forkIO, and generally try to use the async library
comments powered by Disqus

Copyright © 2013-2017 FP Complete Corp. All rights reserved