diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,3 +1,76 @@
+# ChangeLog for typed-process
+
+## 0.2.13.0
+
+* Format stdout and stderr in `ExitCodeException` assuming they are in
+  UTF-8.  See [#87](https://github.com/fpco/typed-process/pull/87).
+  Thanks to @9999years for the legwork on this change.
+
+## 0.2.12.0
+
+* Add `getPid`, `exitCodeExceptionWithOutput`,
+  `exitCodeExceptionNoOutput`,
+
+* Re-export `System.Process.Pid`
+
+* Thanks to Rebecca Turner @9999years
+
+## 0.2.11.1
+
+* No user-visible changes
+
+## 0.2.11.0
+
+* Expose more from `System.Process.Typed.Internal`
+
+## 0.2.10.0
+
+* Add `mkPipeStreamSpec`
+
+## 0.2.9.0
+
+* Re-export `StdStream`
+
+## 0.2.8.0
+
+* Re-export `ExitCode`, `ExitSuccess` and `ExitFailure`.
+
+## 0.2.7.0
+
+* Include empty argument in the show instance.
+
+## 0.2.6.3
+
+* Doc improvements
+
+## 0.2.6.2
+
+* Doc improvements
+
+## 0.2.6.1
+
+* Doc improvements
+
+## 0.2.6.0
+
+* The cleanup thread applies an `unmask` to the actions which wait for a
+  process to exit, allowing the action to be interruptible.
+
+## 0.2.5.0
+
+* Add a `nullStream` [#24](https://github.com/fpco/typed-process/pull/24)
+* Add `withProcessWait`, `withProcessWait_`, `withProcessTerm`, and `withProcessTerm_`
+  [#25](https://github.com/fpco/typed-process/issues/25)
+
+## 0.2.4.1
+
+* Fix a `Handle` leak in `withProcessInterleave` and its derivatives.
+
+## 0.2.4.0
+
+* Add `readProcessInterleaved` and `readProcessInterleaved_` to support
+  capturing output from stdout and stderr in a single ByteString value.
+
 ## 0.2.3.0
 
 * Add support for the single-threaded runtime via polling
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
 ## typed-process
 
-[![Build Status](https://travis-ci.org/fpco/typed-process.svg?branch=master)](https://travis-ci.org/fpco/typed-process) [![Build status](https://ci.appveyor.com/api/projects/status/bhh7aekbgeqp7g5j/branch/master?svg=true)](https://ci.appveyor.com/project/snoyberg/typed-process/branch/master)
+[![Tests](https://github.com/fpco/typed-process/actions/workflows/tests.yml/badge.svg)](https://github.com/fpco/typed-process/actions/workflows/tests.yml)
 
 API level documentation (Haddocks) may be [found on
 Stackage](https://www.stackage.org/package/typed-process).
 
 This library provides the ability to launch and interact with external
 processes. It wraps around the
-[process library](https://haskell-lang.org/library/process), and
+[process library](https://hackage.haskell.org/package/process), and
 intends to improve upon it by:
 
 1. Using type variables to represent the standard streams, making them
@@ -29,7 +29,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.IO (hPutStr, hClose)
 import System.Process.Typed
@@ -60,7 +60,7 @@
     let catConfig = setStdin createPipe
                   $ setStdout byteStringOutput
                   $ proc "cat" ["/etc/hosts", "-", "/etc/group"]
-    withProcess_ catConfig $ \p -> do
+    withProcessWait_ catConfig $ \p -> do
         hPutStr (getStdin p) "\n\nHELLO\n"
         hPutStr (getStdin p) "WORLD\n\n\n"
         hClose (getStdin p)
@@ -85,14 +85,15 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
 main :: IO ()
 main = do
     let dateConfig :: ProcessConfig () () ()
-        dateConfig = "date"
+        dateConfig = proc "date" []
+        -- alternatively: `shell "date"` or just "date"
 
     process <- startProcess dateConfig
     exitCode <- waitExitCode (process :: Process () () ())
@@ -108,17 +109,17 @@
 parameters in the next section.)
 
 Instead of explicitly dealing with `startProcess` and `stopProcess`,
-it's recommended to instead use `withProcess`, which uses the bracket
+it's recommended to instead use `withProcessWait`, which uses the bracket
 pattern and is exception safe:
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
 main :: IO ()
-main = withProcess "date" $ \process -> do
+main = withProcessWait "date" $ \process -> do
     exitCode <- waitExitCode (process :: Process () () ())
     print exitCode
 ```
@@ -129,7 +130,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -157,7 +158,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -189,7 +190,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -220,7 +221,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -234,7 +235,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -247,12 +248,12 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
 main :: IO ()
-main = withProcess "false" checkExitCode
+main = withProcessWait "false" checkExitCode
 ```
 
 ## Reading from a process
@@ -265,7 +266,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 import System.Exit (ExitCode)
@@ -291,7 +292,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 import Data.ByteString.Lazy (ByteString)
@@ -312,12 +313,12 @@
 from a process to a file. This is superior to the memory approach as
 it does not have the risk of using large amounts of memory, though it
 is more inconvenient. Together with the
-[`UnliftIO.Temporary`](https://www.stackage.org/haddock/lts-10.2/unliftio-0.2.2.0/UnliftIO-Temporary.html), we
+[`UnliftIO.Temporary`](https://www.stackage.org/haddock/lts/unliftio/UnliftIO-Temporary.html), we
 can do some nice things:
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 import UnliftIO.Temporary (withSystemTempFile)
@@ -341,7 +342,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 import System.IO (hClose)
@@ -371,7 +372,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -383,7 +384,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
@@ -396,7 +397,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 import System.IO
@@ -422,7 +423,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 import System.IO
@@ -456,7 +457,7 @@
 
 ```haskell
 #!/usr/bin/env stack
--- stack --resolver lts-10.2 script
+-- stack --resolver lts-16.27 script
 {-# LANGUAGE OverloadedStrings #-}
 import System.Process.Typed
 
diff --git a/src/System/Process/Typed.hs b/src/System/Process/Typed.hs
--- a/src/System/Process/Typed.hs
+++ b/src/System/Process/Typed.hs
@@ -3,10 +3,22 @@
 {-# LANGUAGE DeriveDataTypeable #-}
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE DataKinds #-}
-{-# LANGUAGE KindSignatures #-}
 {-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE ScopedTypeVariables #-}
--- | Please see the README.md file for examples of using this API.
+-- | The simplest way to get started with this API is to turn on
+-- @OverloadedStrings@ and call 'runProcess'.  The following will
+-- write the contents of @/home@ to @stdout@ and then print the exit
+-- code (on a UNIX system).
+--
+-- @
+-- {-\# LANGUAGE OverloadedStrings \#-}
+--
+-- 'runProcess' "ls -l /home" >>= print
+-- @
+--
+-- Please see the [README.md](https://github.com/fpco/typed-process#readme)
+-- file for more examples of using this API.
 module System.Process.Typed
     ( -- * Types
       ProcessConfig
@@ -19,6 +31,8 @@
     , proc
     , shell
 
+      -- | #processconfigsetters#
+
       -- ** Setters
     , setStdin
     , setStdout
@@ -42,9 +56,12 @@
     , setChildUserInherit
 #endif
 
+      -- | #streamspecs#
+
       -- * Stream specs
-    , mkStreamSpec
+      -- ** Built-in stream specs
     , inherit
+    , nullStream
     , closed
     , byteStringInput
     , byteStringOutput
@@ -52,20 +69,36 @@
     , useHandleOpen
     , useHandleClose
 
+    -- ** Create your own stream spec
+    , mkStreamSpec
+    , mkPipeStreamSpec
+
+      -- | #launchaprocess#
+
       -- * Launch a process
+    , runProcess
+    , readProcess
+    , readProcessStdout
+    , readProcessStderr
+    , readProcessInterleaved
+    , withProcessWait
+    , withProcessTerm
     , startProcess
     , stopProcess
-    , withProcess
-    , withProcess_
-    , readProcess
-    , readProcess_
-    , runProcess
+      -- ** Exception-throwing functions
+      -- | The functions ending in underbar (@_@) are the same as
+      -- their counterparts without underbar but instead of returning
+      -- an 'ExitCode' they throw 'ExitCodeException' on failure.
     , runProcess_
-    , readProcessStdout
+    , readProcess_
     , readProcessStdout_
-    , readProcessStderr
     , readProcessStderr_
+    , readProcessInterleaved_
+    , withProcessWait_
+    , withProcessTerm_
 
+      -- | #interactwithaprocess#
+
       -- * Interact with a process
 
       -- ** Process exit code
@@ -76,6 +109,9 @@
     , checkExitCode
     , checkExitCodeSTM
 
+      -- ** Process ID
+    , getPid
+
       -- ** Process streams
     , getStdin
     , getStdout
@@ -83,32 +119,35 @@
 
       -- * Exceptions
     , ExitCodeException (..)
+    , exitCodeExceptionWithOutput
+    , exitCodeExceptionNoOutput
     , ByteStringOutputException (..)
+
+      -- * Re-exports
+    , ExitCode (..)
+    , P.StdStream (..)
+    , P.Pid
+
       -- * Unsafe functions
     , unsafeProcessHandle
+      -- * Deprecated functions
+    , withProcess
+    , withProcess_
     ) where
 
-import qualified Data.ByteString as S
-import Data.ByteString.Lazy.Internal (defaultChunkSize)
-import Control.Exception (assert, evaluate, throwIO, Exception, SomeException, finally, bracket, onException, catch, try)
-import Control.Monad (void)
+import Control.Exception hiding (bracket, finally)
 import Control.Monad.IO.Class
 import qualified System.Process as P
-import Data.Typeable (Typeable)
-import System.IO (Handle, hClose)
+import System.IO (hClose)
 import System.IO.Error (isPermissionError)
 import Control.Concurrent (threadDelay)
-import Control.Concurrent.Async (async, cancel, waitCatch)
+import Control.Concurrent.Async (asyncWithUnmask, cancel, waitCatch)
 import Control.Concurrent.STM (newEmptyTMVarIO, atomically, putTMVar, TMVar, readTMVar, tryReadTMVar, STM, tryPutTMVar, throwSTM, catchSTM)
-import System.Exit (ExitCode (ExitSuccess))
+import System.Exit (ExitCode (ExitSuccess, ExitFailure))
+import System.Process.Typed.Internal
 import qualified Data.ByteString.Lazy as L
-import qualified Data.ByteString.Lazy.Char8 as L8
-import Data.String (IsString (fromString))
 import GHC.RTS.Flags (getConcFlags, ctxtSwitchTime)
-
-#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
-import System.Posix.Types (GroupID, UserID)
-#endif
+import Control.Monad.IO.Unlift
 
 #if !MIN_VERSION_base(4, 8, 0)
 import Control.Applicative (Applicative (..), (<$>), (<$))
@@ -118,121 +157,12 @@
 import qualified System.Process.Internals as P (createProcess_)
 #endif
 
--- | An abstract configuration for a process, which can then be
--- launched into an actual running 'Process'. Takes three type
--- parameters, providing the types of standard input, standard output,
--- and standard error, respectively.
---
--- There are three ways to construct a value of this type:
---
--- * With the 'proc' smart constructor, which takes a command name and
--- a list of arguments.
---
--- * With the 'shell' smart constructor, which takes a shell string
---
--- * With the 'IsString' instance via OverloadedStrings. If you
--- provide it a string with no spaces (e.g., @"date"@), it will
--- treat it as a raw command with no arguments (e.g., @proc "date"
--- []@). If it has spaces, it will use @shell@.
---
--- In all cases, the default for all three streams is to inherit the
--- streams from the parent process. For other settings, see the
--- setters below for default values.
---
--- @since 0.1.0.0
-data ProcessConfig stdin stdout stderr = ProcessConfig
-    { pcCmdSpec :: !P.CmdSpec
-    , pcStdin :: !(StreamSpec 'STInput stdin)
-    , pcStdout :: !(StreamSpec 'STOutput stdout)
-    , pcStderr :: !(StreamSpec 'STOutput stderr)
-    , pcWorkingDir :: !(Maybe FilePath)
-    , pcEnv :: !(Maybe [(String, String)])
-    , pcCloseFds :: !Bool
-    , pcCreateGroup :: !Bool
-    , pcDelegateCtlc :: !Bool
-
-#if MIN_VERSION_process(1, 3, 0)
-    , pcDetachConsole :: !Bool
-    , pcCreateNewConsole :: !Bool
-    , pcNewSession :: !Bool
-#endif
-
-#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
-    , pcChildGroup :: !(Maybe GroupID)
-    , pcChildUser :: !(Maybe UserID)
-#endif
-    }
-instance Show (ProcessConfig stdin stdout stderr) where
-    show pc = concat
-        [ case pcCmdSpec pc of
-            P.ShellCommand s -> "Shell command: " ++ s
-            P.RawCommand x xs -> "Raw command: " ++ unwords (map escape (x:xs))
-        , "\n"
-        , case pcWorkingDir pc of
-            Nothing -> ""
-            Just wd -> concat
-                [ "Run from: "
-                , wd
-                , "\n"
-                ]
-        , case pcEnv pc of
-            Nothing -> ""
-            Just e -> unlines
-                $ "Modified environment:"
-                : map (\(k, v) -> concat [k, "=", v]) e
-        ]
-      where
-        escape x
-            | any (`elem` " \\\"'") x = show x
-            | otherwise = x
-instance (stdin ~ (), stdout ~ (), stderr ~ ())
-  => IsString (ProcessConfig stdin stdout stderr) where
-    fromString s
-        | any (== ' ') s = shell s
-        | otherwise = proc s []
-
--- | Whether a stream is an input stream or output stream. Note that
--- this is from the perspective of the /child process/, so that a
--- child's standard input stream is an @STInput@, even though the
--- parent process will be writing to it.
---
--- @since 0.1.0.0
-data StreamType = STInput | STOutput
-
--- | A specification for how to create one of the three standard child
--- streams. See examples below.
---
--- @since 0.1.0.0
-data StreamSpec (streamType :: StreamType) a = StreamSpec
-    { ssStream :: !P.StdStream
-    , ssCreate :: !(ProcessConfig () () () -> Maybe Handle -> Cleanup a)
-    }
-    deriving Functor
-
--- | This instance uses 'byteStringInput' to convert a raw string into
--- a stream of input for a child process.
---
--- @since 0.1.0.0
-instance (streamType ~ 'STInput, res ~ ())
-  => IsString (StreamSpec streamType res) where
-    fromString = byteStringInput . fromString
-
--- | Internal type, to make for easier composition of cleanup actions.
---
--- @since 0.1.0.0
-newtype Cleanup a = Cleanup { runCleanup :: IO (a, IO ()) }
-    deriving Functor
-instance Applicative Cleanup where
-    pure x = Cleanup (return (x, return ()))
-    Cleanup f <*> Cleanup x = Cleanup $ do
-        (f', c1) <- f
-        (`onException` c1) $ do
-            (x', c2) <- x
-            return (f' x', c1 `finally` c2)
-
 -- | A running process. The three type parameters provide the type of
 -- the standard input, standard output, and standard error streams.
 --
+-- To interact with a @Process@ use the functions from the section
+-- [Interact with a process](#interactwithaprocess).
+--
 -- @since 0.1.0.0
 data Process stdin stdout stderr = Process
     { pConfig :: !(ProcessConfig () () ())
@@ -246,440 +176,121 @@
 instance Show (Process stdin stdout stderr) where
     show p = "Running process: " ++ show (pConfig p)
 
--- | Internal helper
-defaultProcessConfig :: ProcessConfig () () ()
-defaultProcessConfig = ProcessConfig
-    { pcCmdSpec = P.ShellCommand ""
-    , pcStdin = inherit
-    , pcStdout = inherit
-    , pcStderr = inherit
-    , pcWorkingDir = Nothing
-    , pcEnv = Nothing
-    , pcCloseFds = False
-    , pcCreateGroup = False
-    , pcDelegateCtlc = False
-
-#if MIN_VERSION_process(1, 3, 0)
-    , pcDetachConsole = False
-    , pcCreateNewConsole = False
-    , pcNewSession = False
-#endif
-
-#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
-    , pcChildGroup = Nothing
-    , pcChildUser = Nothing
-#endif
-    }
-
--- | Create a 'ProcessConfig' from the given command and arguments.
---
--- @since 0.1.0.0
-proc :: FilePath -> [String] -> ProcessConfig () () ()
-proc cmd args = setProc cmd args defaultProcessConfig
-
--- | Internal helper
-setProc :: FilePath -> [String]
-        -> ProcessConfig stdin stdout stderr
-        -> ProcessConfig stdin stdout stderr
-setProc cmd args p = p { pcCmdSpec = P.RawCommand cmd args }
-
--- | Create a 'ProcessConfig' from the given shell command.
---
--- @since 0.1.0.0
-shell :: String -> ProcessConfig () () ()
-shell cmd = setShell cmd defaultProcessConfig
-
--- | Internal helper
-setShell :: String
-         -> ProcessConfig stdin stdout stderr
-         -> ProcessConfig stdin stdout stderr
-setShell cmd p = p { pcCmdSpec = P.ShellCommand cmd }
-
--- | Set the child's standard input stream to the given 'StreamSpec'.
---
--- Default: 'inherit'
---
--- @since 0.1.0.0
-setStdin :: StreamSpec 'STInput stdin
-         -> ProcessConfig stdin0 stdout stderr
-         -> ProcessConfig stdin stdout stderr
-setStdin spec pc = pc { pcStdin = spec }
-
--- | Set the child's standard output stream to the given 'StreamSpec'.
---
--- Default: 'inherit'
---
--- @since 0.1.0.0
-setStdout :: StreamSpec 'STOutput stdout
-          -> ProcessConfig stdin stdout0 stderr
-          -> ProcessConfig stdin stdout stderr
-setStdout spec pc = pc { pcStdout = spec }
-
--- | Set the child's standard error stream to the given 'StreamSpec'.
---
--- Default: 'inherit'
---
--- @since 0.1.0.0
-setStderr :: StreamSpec 'STOutput stderr
-          -> ProcessConfig stdin stdout stderr0
-          -> ProcessConfig stdin stdout stderr
-setStderr spec pc = pc { pcStderr = spec }
-
--- | Set the working directory of the child process.
---
--- Default: current process's working directory.
---
--- @since 0.1.0.0
-setWorkingDir :: FilePath
-              -> ProcessConfig stdin stdout stderr
-              -> ProcessConfig stdin stdout stderr
-setWorkingDir dir pc = pc { pcWorkingDir = Just dir }
-
--- | Inherit the working directory from the parent process.
---
--- @since 0.2.2.0
-setWorkingDirInherit
-  :: ProcessConfig stdin stdout stderr
-  -> ProcessConfig stdin stdout stderr
-setWorkingDirInherit pc = pc { pcWorkingDir = Nothing }
-
--- | Set the environment variables of the child process.
---
--- Default: current process's environment.
---
--- @since 0.1.0.0
-setEnv :: [(String, String)]
-       -> ProcessConfig stdin stdout stderr
-       -> ProcessConfig stdin stdout stderr
-setEnv env pc = pc { pcEnv = Just env }
-
--- | Inherit the environment variables from the parent process.
---
--- @since 0.2.2.0
-setEnvInherit
-  :: ProcessConfig stdin stdout stderr
-  -> ProcessConfig stdin stdout stderr
-setEnvInherit pc = pc { pcEnv = Nothing }
-
--- | Should we close all file descriptors besides stdin, stdout, and
--- stderr? See 'P.close_fds' for more information.
---
--- Default: False
---
--- @since 0.1.0.0
-setCloseFds
-    :: Bool
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setCloseFds x pc = pc { pcCloseFds = x }
-
--- | Should we create a new process group?
---
--- Default: False
---
--- @since 0.1.0.0
-setCreateGroup
-    :: Bool
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setCreateGroup x pc = pc { pcCreateGroup = x }
-
--- | Delegate handling of Ctrl-C to the child. For more information,
--- see 'P.delegate_ctlc'.
---
--- Default: False
---
--- @since 0.1.0.0
-setDelegateCtlc
-    :: Bool
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setDelegateCtlc x pc = pc { pcDelegateCtlc = x }
-
-#if MIN_VERSION_process(1, 3, 0)
-
--- | Detach console on Windows, see 'P.detach_console'.
---
--- Default: False
---
--- @since 0.1.0.0
-setDetachConsole
-    :: Bool
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setDetachConsole x pc = pc { pcDetachConsole = x }
-
--- | Create new console on Windows, see 'P.create_new_console'.
---
--- Default: False
---
--- @since 0.1.0.0
-setCreateNewConsole
-    :: Bool
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setCreateNewConsole x pc = pc { pcCreateNewConsole = x }
-
--- | Set a new session with the POSIX @setsid@ syscall, does nothing
--- on non-POSIX. See 'P.new_session'.
---
--- Default: False
---
--- @since 0.1.0.0
-setNewSession
-    :: Bool
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setNewSession x pc = pc { pcNewSession = x }
-#endif
-
-#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
--- | Set the child process's group ID with the POSIX @setgid@ syscall,
--- does nothing on non-POSIX. See 'P.child_group'.
---
--- Default: False
---
--- @since 0.1.0.0
-setChildGroup
-    :: GroupID
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setChildGroup x pc = pc { pcChildGroup = Just x }
-
--- | Inherit the group from the parent process.
---
--- @since 0.2.2.0
-setChildGroupInherit
-  :: ProcessConfig stdin stdout stderr
-  -> ProcessConfig stdin stdout stderr
-setChildGroupInherit pc = pc { pcChildGroup = Nothing }
-
--- | Set the child process's user ID with the POSIX @setuid@ syscall,
--- does nothing on non-POSIX. See 'P.child_user'.
---
--- Default: False
---
--- @since 0.1.0.0
-setChildUser
-    :: UserID
-    -> ProcessConfig stdin stdout stderr
-    -> ProcessConfig stdin stdout stderr
-setChildUser x pc = pc { pcChildUser = Just x }
-
--- | Inherit the user from the parent process.
---
--- @since 0.2.2.0
-setChildUserInherit
-  :: ProcessConfig stdin stdout stderr
-  -> ProcessConfig stdin stdout stderr
-setChildUserInherit pc = pc { pcChildUser = Nothing }
-#endif
-
--- | Create a new 'StreamSpec' from the given 'P.StdStream' and a
--- helper function. This function:
---
--- * Takes as input the raw @Maybe Handle@ returned by the
--- 'P.createProcess' function. This will be determined by the
--- 'P.StdStream' argument.
---
--- * Returns the actual stream value @a@, as well as a cleanup
--- * function to be run when calling 'stopProcess'.
---
--- @since 0.1.0.0
-mkStreamSpec :: P.StdStream
-             -> (ProcessConfig () () () -> Maybe Handle -> IO (a, IO ()))
-             -> StreamSpec streamType a
-mkStreamSpec ss f = StreamSpec ss (\pc mh -> Cleanup (f pc mh))
-
--- | A stream spec which simply inherits the stream of the parent
--- process.
---
--- @since 0.1.0.0
-inherit :: StreamSpec anyStreamType ()
-inherit = mkStreamSpec P.Inherit (\_ Nothing -> pure ((), return ()))
-
--- | A stream spec which will close the stream for the child process.
---
--- @since 0.1.0.0
-closed :: StreamSpec anyStreamType ()
-#if MIN_VERSION_process(1, 4, 0)
-closed = mkStreamSpec P.NoStream (\_ Nothing -> pure ((), return ()))
-#else
-closed = mkStreamSpec P.CreatePipe (\_ (Just h) -> (((), return ()) <$ hClose h))
-#endif
-
--- | An input stream spec which sets the input to the given
--- 'L.ByteString'. A separate thread will be forked to write the
--- contents to the child process.
---
--- @since 0.1.0.0
-byteStringInput :: L.ByteString -> StreamSpec 'STInput ()
-byteStringInput lbs = mkStreamSpec P.CreatePipe $ \_ (Just h) -> do
-    void $ async $ do
-        L.hPut h lbs
-        hClose h
-    return ((), hClose h)
-
--- | Capture the output of a process in a 'L.ByteString'.
---
--- This function will fork a separate thread to consume all input from
--- the process, and will only make the results available when the
--- underlying 'Handle' is closed. As this is provided as an 'STM'
--- action, you can either check if the result is available, or block
--- until it's ready.
---
--- In the event of any exception occurring when reading from the
--- 'Handle', the 'STM' action will throw a
--- 'ByteStringOutputException'.
---
--- @since 0.1.0.0
-byteStringOutput :: StreamSpec 'STOutput (STM L.ByteString)
-byteStringOutput = mkStreamSpec P.CreatePipe $ \pc (Just h) -> do
-    mvar <- newEmptyTMVarIO
-
-    void $ async $ do
-        let loop front = do
-                bs <- S.hGetSome h defaultChunkSize
-                if S.null bs
-                    then atomically $ putTMVar mvar $ Right $ L.fromChunks $ front []
-                    else loop $ front . (bs:)
-        loop id `catch` \e -> do
-            atomically $ void $ tryPutTMVar mvar $ Left $ ByteStringOutputException e pc
-            throwIO e
-
-    return (readTMVar mvar >>= either throwSTM return, hClose h)
-
--- | Create a new pipe between this process and the child, and return
--- a 'Handle' to communicate with the child.
---
--- @since 0.1.0.0
-createPipe :: StreamSpec anyStreamType Handle
-createPipe = mkStreamSpec P.CreatePipe $ \_ (Just h) -> return (h, hClose h)
-
--- | Use the provided 'Handle' for the child process, and when the
--- process exits, do /not/ close it. This is useful if, for example,
--- you want to have multiple processes write to the same log file
--- sequentially.
---
--- @since 0.1.0.0
-useHandleOpen :: Handle -> StreamSpec anyStreamType ()
-useHandleOpen h = mkStreamSpec (P.UseHandle h) $ \_ Nothing -> return ((), return ())
-
--- | Use the provided 'Handle' for the child process, and when the
--- process exits, close it. If you have no reason to keep the 'Handle'
--- open, you should use this over 'useHandleOpen'.
---
--- @since 0.1.0.0
-useHandleClose :: Handle -> StreamSpec anyStreamType ()
-useHandleClose h = mkStreamSpec (P.UseHandle h) $ \_ Nothing -> return ((), hClose h)
-
 -- | Launch a process based on the given 'ProcessConfig'. You should
--- ensure that you close 'stopProcess' on the result. It's usually
+-- ensure that you call 'stopProcess' on the result. It's usually
 -- better to use one of the functions in this module which ensures
--- 'stopProcess' is called, such as 'withProcess'.
+-- 'stopProcess' is called, such as 'withProcessWait'.
 --
 -- @since 0.1.0.0
 startProcess :: MonadIO m
              => ProcessConfig stdin stdout stderr
+             -- ^ 
              -> m (Process stdin stdout stderr)
 startProcess pConfig'@ProcessConfig {..} = liftIO $ do
-    let cp0 =
-            case pcCmdSpec of
-                P.ShellCommand cmd -> P.shell cmd
-                P.RawCommand cmd args -> P.proc cmd args
-        cp = cp0
-            { P.std_in = ssStream pcStdin
-            , P.std_out = ssStream pcStdout
-            , P.std_err = ssStream pcStderr
-            , P.cwd = pcWorkingDir
-            , P.env = pcEnv
-            , P.close_fds = pcCloseFds
-            , P.create_group = pcCreateGroup
-            , P.delegate_ctlc = pcDelegateCtlc
+    ssStream pcStdin $ \realStdin ->
+      ssStream pcStdout $ \realStdout ->
+        ssStream pcStderr $ \realStderr -> do
 
+          let cp0 =
+                  case pcCmdSpec of
+                      P.ShellCommand cmd -> P.shell cmd
+                      P.RawCommand cmd args -> P.proc cmd args
+              cp = cp0
+                  { P.std_in = realStdin
+                  , P.std_out = realStdout
+                  , P.std_err = realStderr
+                  , P.cwd = pcWorkingDir
+                  , P.env = pcEnv
+                  , P.close_fds = pcCloseFds
+                  , P.create_group = pcCreateGroup
+                  , P.delegate_ctlc = pcDelegateCtlc
+
 #if MIN_VERSION_process(1, 3, 0)
-            , P.detach_console = pcDetachConsole
-            , P.create_new_console = pcCreateNewConsole
-            , P.new_session = pcNewSession
+                  , P.detach_console = pcDetachConsole
+                  , P.create_new_console = pcCreateNewConsole
+                  , P.new_session = pcNewSession
 #endif
 
 #if MIN_VERSION_process(1, 4, 0) && !WINDOWS
-            , P.child_group = pcChildGroup
-            , P.child_user = pcChildUser
+                  , P.child_group = pcChildGroup
+                  , P.child_user = pcChildUser
 #endif
 
-            }
+                  }
 
-    (minH, moutH, merrH, pHandle) <- P.createProcess_ "startProcess" cp
+          (minH, moutH, merrH, pHandle) <- P.createProcess_ "startProcess" cp
 
-    ((pStdin, pStdout, pStderr), pCleanup1) <- runCleanup $ (,,)
-        <$> ssCreate pcStdin  pConfig minH
-        <*> ssCreate pcStdout pConfig moutH
-        <*> ssCreate pcStderr pConfig merrH
+          ((pStdin, pStdout, pStderr), pCleanup1) <- runCleanup $ (,,)
+              <$> ssCreate pcStdin  pConfig minH
+              <*> ssCreate pcStdout pConfig moutH
+              <*> ssCreate pcStderr pConfig merrH
 
-    pExitCode <- newEmptyTMVarIO
-    waitingThread <- async $ do
-        ec <-
-          if multiThreadedRuntime
-            then P.waitForProcess pHandle
-            else do
-              switchTime <- (fromIntegral . (`div` 1000) . ctxtSwitchTime)
-                        <$> getConcFlags
-              let minDelay = 1
-                  maxDelay = max minDelay switchTime
-                  loop delay = do
-                    threadDelay delay
-                    mec <- P.getProcessExitCode pHandle
-                    case mec of
-                      Nothing -> loop $ min maxDelay (delay * 2)
-                      Just ec -> pure ec
-              loop minDelay
-        atomically $ putTMVar pExitCode ec
-        return ec
+          pExitCode <- newEmptyTMVarIO
+          waitingThread <- asyncWithUnmask $ \unmask -> do
+              ec <- unmask $ -- make sure the masking state from a bracket isn't inherited
+                if multiThreadedRuntime
+                  then P.waitForProcess pHandle
+                  else do
+                    switchTime <- fromIntegral . (`div` 1000) . ctxtSwitchTime
+                              <$> getConcFlags
+                    let minDelay = 1
+                        maxDelay = max minDelay switchTime
+                        loop delay = do
+                          threadDelay delay
+                          mec <- P.getProcessExitCode pHandle
+                          case mec of
+                            Nothing -> loop $ min maxDelay (delay * 2)
+                            Just ec -> pure ec
+                    loop minDelay
+              atomically $ putTMVar pExitCode ec
+              return ec
 
-    let pCleanup = pCleanup1 `finally` do
-            -- First: stop calling waitForProcess, so that we can
-            -- avoid race conditions where the process is removed from
-            -- the system process table while we're trying to
-            -- terminate it.
-            cancel waitingThread
+          let pCleanup = pCleanup1 `finally` do
+                  -- First: stop calling waitForProcess, so that we can
+                  -- avoid race conditions where the process is removed from
+                  -- the system process table while we're trying to
+                  -- terminate it.
+                  cancel waitingThread
 
-            -- Now check if the process had already exited
-            eec <- waitCatch waitingThread
+                  -- Now check if the process had already exited
+                  eec <- waitCatch waitingThread
 
-            case eec of
-                -- Process already exited, nothing to do
-                Right _ec -> return ()
+                  case eec of
+                      -- Process already exited, nothing to do
+                      Right _ec -> return ()
 
-                -- Process didn't exit yet, let's terminate it and
-                -- then call waitForProcess ourselves
-                Left _ -> do
-                    eres <- try $ P.terminateProcess pHandle
-                    ec <-
-                      case eres of
-                        Left e
-                          -- On Windows, with the single-threaded runtime, it
-                          -- seems that if a process has already exited, the
-                          -- call to terminateProcess will fail with a
-                          -- permission denied error. To work around this, we
-                          -- catch this exception and then immediately
-                          -- waitForProcess. There's a chance that there may be
-                          -- other reasons for this permission error to appear,
-                          -- in which case this code may allow us to wait too
-                          -- long for a child process instead of erroring out.
-                          -- Recommendation: always use the multi-threaded
-                          -- runtime!
-                          | isPermissionError e && not multiThreadedRuntime && isWindows ->
-                            P.waitForProcess pHandle
-                          | otherwise -> throwIO e
-                        Right () -> P.waitForProcess pHandle
-                    success <- atomically $ tryPutTMVar pExitCode ec
-                    evaluate $ assert success ()
+                      -- Process didn't exit yet, let's terminate it and
+                      -- then call waitForProcess ourselves
+                      Left _ -> do
+                          terminateProcess pHandle
+                          ec <- P.waitForProcess pHandle
+                          success <- atomically $ tryPutTMVar pExitCode ec
+                          evaluate $ assert success ()
 
-    return Process {..}
+          return Process {..}
   where
     pConfig = clearStreams pConfig'
 
+    terminateProcess pHandle = do
+      eres <- try $ P.terminateProcess pHandle
+      case eres of
+          Left e
+            -- On Windows, with the single-threaded runtime, it
+            -- seems that if a process has already exited, the
+            -- call to terminateProcess will fail with a
+            -- permission denied error. To work around this, we
+            -- catch this exception and then immediately
+            -- waitForProcess. There's a chance that there may be
+            -- other reasons for this permission error to appear,
+            -- in which case this code may allow us to wait too
+            -- long for a child process instead of erroring out.
+            -- Recommendation: always use the multi-threaded
+            -- runtime!
+            | isPermissionError e && not multiThreadedRuntime && isWindows ->
+              pure ()
+            | otherwise -> throwIO e
+          Right () -> pure ()
+
 foreign import ccall unsafe "rtsSupportsBoundThreads"
   multiThreadedRuntime :: Bool
 
@@ -705,28 +316,90 @@
 -- | Uses the bracket pattern to call 'startProcess' and ensures that
 -- 'stopProcess' is called.
 --
--- In version 0.2.0.0, this function was monomorphized to @IO@ to
--- avoid a dependency on the exceptions package.
+-- This function is usually /not/ what you want. You're likely better
+-- off using 'withProcessWait'. See
+-- <https://github.com/fpco/typed-process/issues/25>.
 --
--- @since 0.1.0.0
-withProcess :: ProcessConfig stdin stdout stderr
-            -> (Process stdin stdout stderr -> IO a)
-            -> IO a
-withProcess config = bracket (startProcess config) stopProcess
+-- @since 0.2.5.0
+withProcessTerm :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> (Process stdin stdout stderr -> m a)
+  -- ^ 
+  -> m a
+withProcessTerm config = bracket (startProcess config) stopProcess
 
--- | Same as 'withProcess', but also calls 'checkExitCode'
+-- | Uses the bracket pattern to call 'startProcess'. Unlike
+-- 'withProcessTerm', this function will wait for the child process to
+-- exit, and only kill it with 'stopProcess' in the event that the
+-- inner function throws an exception.
 --
--- In version 0.2.0.0, this function was monomorphized to @IO@ to
--- avoid a dependency on the exceptions package.
+-- To interact with a @Process@ use the functions from the section
+-- [Interact with a process](#interactwithaprocess).
 --
+-- @since 0.2.5.0
+withProcessWait :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> (Process stdin stdout stderr -> m a)
+  -- ^ 
+  -> m a
+withProcessWait config f =
+  bracket
+    (startProcess config)
+    stopProcess
+    (\p -> f p <* waitExitCode p)
+
+-- | Deprecated synonym for 'withProcessTerm'.
+--
 -- @since 0.1.0.0
-withProcess_ :: ProcessConfig stdin stdout stderr
-             -> (Process stdin stdout stderr -> IO a)
-             -> IO a
-withProcess_ config = bracket
+withProcess :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdout stderr
+  -> (Process stdin stdout stderr -> m a)
+  -> m a
+withProcess = withProcessTerm
+{-# DEPRECATED withProcess "Please consider using `withProcessWait`, or instead use `withProcessTerm`" #-}
+
+-- | Same as 'withProcessTerm', but also calls 'checkExitCode'
+--
+-- To interact with a @Process@ use the functions from the section
+-- [Interact with a process](#interactwithaprocess).
+--
+-- @since 0.2.5.0
+withProcessTerm_ :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> (Process stdin stdout stderr -> m a)
+  -- ^ 
+  -> m a
+withProcessTerm_ config = bracket
     (startProcess config)
     (\p -> stopProcess p `finally` checkExitCode p)
 
+-- | Same as 'withProcessWait', but also calls 'checkExitCode'
+--
+-- @since 0.2.5.0
+withProcessWait_ :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> (Process stdin stdout stderr -> m a)
+  -- ^ 
+  -> m a
+withProcessWait_ config f = bracket
+    (startProcess config)
+    stopProcess
+    (\p -> f p <* checkExitCode p)
+
+-- | Deprecated synonym for 'withProcessTerm_'.
+--
+-- @since 0.1.0.0
+withProcess_ :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdout stderr
+  -> (Process stdin stdout stderr -> m a)
+  -> m a
+withProcess_ = withProcessTerm_
+{-# DEPRECATED withProcess_ "Please consider using `withProcessWait_`, or instead use `withProcessTerm_`" #-}
+
 -- | Run a process, capture its standard output and error as a
 -- 'L.ByteString', wait for it to complete, and then return its exit
 -- code, output, and error.
@@ -737,6 +410,7 @@
 -- @since 0.1.0.0
 readProcess :: MonadIO m
             => ProcessConfig stdin stdoutIgnored stderrIgnored
+            -- ^ 
             -> m (ExitCode, L.ByteString, L.ByteString)
 readProcess pc =
     liftIO $ withProcess pc' $ \p -> atomically $ (,,)
@@ -750,9 +424,12 @@
 -- | Same as 'readProcess', but instead of returning the 'ExitCode',
 -- checks it with 'checkExitCode'.
 --
+-- Exceptions thrown by this function will include stdout and stderr.
+--
 -- @since 0.1.0.0
 readProcess_ :: MonadIO m
              => ProcessConfig stdin stdoutIgnored stderrIgnored
+             -- ^ 
              -> m (L.ByteString, L.ByteString)
 readProcess_ pc =
     liftIO $ withProcess pc' $ \p -> atomically $ do
@@ -773,6 +450,7 @@
 readProcessStdout
   :: MonadIO m
   => ProcessConfig stdin stdoutIgnored stderr
+  -- ^ 
   -> m (ExitCode, L.ByteString)
 readProcessStdout pc =
     liftIO $ withProcess pc' $ \p -> atomically $ (,)
@@ -784,10 +462,13 @@
 -- | Same as 'readProcessStdout', but instead of returning the
 -- 'ExitCode', checks it with 'checkExitCode'.
 --
+-- Exceptions thrown by this function will include stdout.
+--
 -- @since 0.2.1.0
 readProcessStdout_
   :: MonadIO m
   => ProcessConfig stdin stdoutIgnored stderr
+  -- ^ 
   -> m L.ByteString
 readProcessStdout_ pc =
     liftIO $ withProcess pc' $ \p -> atomically $ do
@@ -799,12 +480,14 @@
   where
     pc' = setStdout byteStringOutput pc
 
--- | Same as 'readProcess', but only read the stderr of the process. Original settings for stderr remain.
+-- | Same as 'readProcess', but only read the stderr of the process.
+-- Original settings for stdout remain.
 --
 -- @since 0.2.1.0
 readProcessStderr
   :: MonadIO m
-  => ProcessConfig stdin stderrIgnored stderr
+  => ProcessConfig stdin stdout stderrIgnored
+  -- ^ 
   -> m (ExitCode, L.ByteString)
 readProcessStderr pc =
     liftIO $ withProcess pc' $ \p -> atomically $ (,)
@@ -816,10 +499,13 @@
 -- | Same as 'readProcessStderr', but instead of returning the
 -- 'ExitCode', checks it with 'checkExitCode'.
 --
+-- Exceptions thrown by this function will include stderr.
+--
 -- @since 0.2.1.0
 readProcessStderr_
   :: MonadIO m
-  => ProcessConfig stdin stderrIgnored stderr
+  => ProcessConfig stdin stdout stderrIgnored
+  -- ^ 
   -> m L.ByteString
 readProcessStderr_ pc =
     liftIO $ withProcess pc' $ \p -> atomically $ do
@@ -831,13 +517,75 @@
   where
     pc' = setStderr byteStringOutput pc
 
+withProcessInterleave :: (MonadUnliftIO m)
+  => ProcessConfig stdin stdoutIgnored stderrIgnored
+  -- ^ 
+  -> (Process stdin (STM L.ByteString) () -> m a)
+  -- ^ 
+  -> m a
+withProcessInterleave pc inner =
+    -- Create a pipe to be shared for both stdout and stderr
+    bracket P.createPipe (\(r, w) -> hClose r >> hClose w) $ \(readEnd, writeEnd) -> do
+        -- Use the writer end of the pipe for both stdout and stderr. For
+        -- the stdout half, use byteStringFromHandle to read the data into
+        -- a lazy ByteString in memory.
+        let pc' = setStdout (mkStreamSpec (P.UseHandle writeEnd) (\pc'' _ -> byteStringFromHandle pc'' readEnd))
+                $ setStderr (useHandleOpen writeEnd)
+                  pc
+        withProcess pc' $ \p -> do
+          -- Now that the process is forked, close the writer end of this
+          -- pipe, otherwise the reader end will never give an EOF.
+          liftIO $ hClose writeEnd
+          inner p
+
+-- | Same as 'readProcess', but interleaves stderr with stdout.
+--
+-- Motivation: Use this function if you need stdout interleaved with stderr
+-- output (e.g. from an HTTP server) in order to debug failures.
+--
+-- @since 0.2.4.0
+readProcessInterleaved
+  :: MonadIO m
+  => ProcessConfig stdin stdoutIgnored stderrIgnored
+  -- ^ 
+  -> m (ExitCode, L.ByteString)
+readProcessInterleaved pc =
+    liftIO $
+    withProcessInterleave pc $ \p ->
+    atomically $ (,)
+      <$> waitExitCodeSTM p
+      <*> getStdout p
+
+-- | Same as 'readProcessInterleaved', but instead of returning the 'ExitCode',
+-- checks it with 'checkExitCode'.
+--
+-- Exceptions thrown by this function will include stdout.
+--
+-- @since 0.2.4.0
+readProcessInterleaved_
+  :: MonadIO m
+  => ProcessConfig stdin stdoutIgnored stderrIgnored
+  -- ^ 
+  -> m L.ByteString
+  -- ^ 
+readProcessInterleaved_ pc =
+    liftIO $
+    withProcessInterleave pc $ \p -> atomically $ do
+      stdout' <- getStdout p
+      checkExitCodeSTM p `catchSTM` \ece -> throwSTM ece
+        { eceStdout = stdout'
+        }
+      return stdout'
+
 -- | Run the given process, wait for it to exit, and returns its
 -- 'ExitCode'.
 --
 -- @since 0.1.0.0
 runProcess :: MonadIO m
            => ProcessConfig stdin stdout stderr
+           -- ^ 
            -> m ExitCode
+           -- ^ 
 runProcess pc = liftIO $ withProcess pc waitExitCode
 
 -- | Same as 'runProcess', but instead of returning the
@@ -846,6 +594,7 @@
 -- @since 0.1.0.0
 runProcess_ :: MonadIO m
             => ProcessConfig stdin stdout stderr
+            -- ^ 
             -> m ()
 runProcess_ pc = liftIO $ withProcess pc checkExitCode
 
@@ -876,6 +625,9 @@
 -- | Wait for a process to exit, and ensure that it exited
 -- successfully. If not, throws an 'ExitCodeException'.
 --
+-- Exceptions thrown by this function will not include stdout or stderr (This prevents unbounded memory usage from reading them into memory).
+-- However, some callers such as 'readProcess_' catch the exception, add the stdout and stderr, and rethrow.
+--
 -- @since 0.1.0.0
 checkExitCode :: MonadIO m => Process stdin stdout stderr -> m ()
 checkExitCode = liftIO . atomically . checkExitCodeSTM
@@ -888,13 +640,19 @@
     ec <- readTMVar (pExitCode p)
     case ec of
         ExitSuccess -> return ()
-        _ -> throwSTM ExitCodeException
-            { eceExitCode = ec
-            , eceProcessConfig = clearStreams (pConfig p)
-            , eceStdout = L.empty
-            , eceStderr = L.empty
-            }
+        _ -> throwSTM $ exitCodeExceptionNoOutput p ec
 
+-- | Returns the PID (process ID) of a subprocess.
+--
+-- 'Nothing' is returned if the underlying 'P.ProcessHandle' was already closed.
+-- Otherwise a PID is returned that remains valid as long as the handle is
+-- open. The operating system may reuse the PID as soon as the last handle to
+-- the process is closed.
+--
+-- @since 0.2.12.0
+getPid :: Process stdin stdout stderr -> IO (Maybe P.Pid)
+getPid = P.getPid . pHandle
+
 -- | Internal
 clearStreams :: ProcessConfig stdin stdout stderr -> ProcessConfig () () ()
 clearStreams pc = pc
@@ -921,42 +679,17 @@
 getStderr :: Process stdin stdout stderr -> stderr
 getStderr = pStderr
 
--- | Exception thrown by 'checkExitCode' in the event of a non-success
--- exit code. Note that 'checkExitCode' is called by other functions
--- as well, like 'runProcess_' or 'readProcess_'.
+-- | Get a process's configuration.
 --
--- @since 0.1.0.0
-data ExitCodeException = ExitCodeException
-    { eceExitCode :: ExitCode
-    , eceProcessConfig :: ProcessConfig () () ()
-    , eceStdout :: L.ByteString
-    , eceStderr :: L.ByteString
-    }
-    deriving Typeable
-instance Exception ExitCodeException
-instance Show ExitCodeException where
-    show ece = concat
-        [ "Received "
-        , show (eceExitCode ece)
-        , " when running\n"
-        -- Too much output for an exception if we show the modified
-        -- environment, so hide it
-        , show (eceProcessConfig ece) { pcEnv = Nothing }
-        , if L.null (eceStdout ece)
-            then ""
-            else "Standard output:\n\n" ++ L8.unpack (eceStdout ece)
-        , if L.null (eceStderr ece)
-            then ""
-            else "Standard error:\n\n" ++ L8.unpack (eceStderr ece)
-        ]
-
--- | Wrapper for when an exception is thrown when reading from a child
--- process, used by 'byteStringOutput'.
+-- This is useful for constructing 'ExitCodeException's.
 --
--- @since 0.1.0.0
-data ByteStringOutputException = ByteStringOutputException SomeException (ProcessConfig () () ())
-    deriving (Show, Typeable)
-instance Exception ByteStringOutputException
+-- Note that the stdin, stdout, and stderr streams are stored in the 'Process',
+-- not the 'ProcessConfig', so the returned 'ProcessConfig' will always have
+-- empty stdin, stdout, and stderr values.
+--
+-- @since 0.2.12.0
+getProcessConfig :: Process stdin stdout stderr -> ProcessConfig () () ()
+getProcessConfig = pConfig
 
 -- | Take 'System.Process.ProcessHandle' out of the 'Process'.
 -- This method is needed in cases one need to use low level functions
@@ -969,7 +702,7 @@
 -- This method is considered unsafe because the actions it performs on
 -- the underlying process may overlap with the functionality that
 -- @typed-process@ provides. For example the user should not call
--- 'System.Process.waitForProcess' on the process handle as eiter
+-- 'System.Process.waitForProcess' on the process handle as either
 -- 'System.Process.waitForProcess' or 'stopProcess' will lock.
 -- Additionally, even if process was terminated by the
 -- 'System.Process.terminateProcess' or by sending signal,
@@ -979,3 +712,45 @@
 -- @since 0.1.1
 unsafeProcessHandle :: Process stdin stdout stderr -> P.ProcessHandle
 unsafeProcessHandle = pHandle
+
+-- | Get an 'ExitCodeException' containing the process's stdout and stderr data.
+--
+-- Note that this will call 'waitExitCode' to block until the process exits, if
+-- it has not exited already.
+--
+-- Unlike 'checkExitCode' and similar, this will return an 'ExitCodeException'
+-- even if the process exits with 'ExitSuccess'.
+--
+-- @since 0.2.12.0
+exitCodeExceptionWithOutput :: MonadIO m
+                            => Process stdin (STM L.ByteString) (STM L.ByteString)
+                            -> m ExitCodeException
+exitCodeExceptionWithOutput process = liftIO $ atomically $ do
+    exitCode <- waitExitCodeSTM process
+    stdout <- getStdout process
+    stderr <- getStderr process
+    pure ExitCodeException
+        { eceExitCode = exitCode
+        , eceProcessConfig = pConfig process
+        , eceStdout = stdout
+        , eceStderr = stderr
+        }
+
+-- | Get an 'ExitCodeException' containing no data other than the exit code and
+-- process config.
+--
+-- Unlike 'checkExitCode' and similar, this will return an 'ExitCodeException'
+-- even if the process exits with 'ExitSuccess'.
+--
+-- @since 0.2.12.0
+exitCodeExceptionNoOutput :: Process stdin stdout stderr
+                          -> ExitCode
+                          -> ExitCodeException
+exitCodeExceptionNoOutput process exitCode =
+    ExitCodeException
+        { eceExitCode = exitCode
+        , eceProcessConfig = pConfig process
+        , eceStdout = L.empty
+        , eceStderr = L.empty
+        }
+
diff --git a/src/System/Process/Typed/Internal.hs b/src/System/Process/Typed/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/System/Process/Typed/Internal.hs
@@ -0,0 +1,655 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE TypeOperators #-}
+{-# LANGUAGE DeriveDataTypeable #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+-- | This module is __internal__ and its contents may change without a warning
+-- or announcement. It is not subject to the PVP.
+module System.Process.Typed.Internal where
+
+import qualified Data.ByteString as S
+import Data.ByteString.Lazy.Internal (defaultChunkSize)
+import qualified Control.Exception as E
+import Control.Exception hiding (bracket, finally, handle)
+import Control.Monad (void)
+import qualified System.Process as P
+import Data.Typeable (Typeable)
+import System.IO (Handle, hClose, IOMode(ReadWriteMode), withBinaryFile)
+import Control.Concurrent.Async (async)
+import Control.Concurrent.STM (newEmptyTMVarIO, atomically, putTMVar, readTMVar, STM, tryPutTMVar, throwSTM)
+import System.Exit (ExitCode)
+import qualified Data.ByteString.Lazy as L
+import Data.String (IsString (fromString))
+import Control.Monad.IO.Unlift
+import qualified Data.Text.Encoding.Error as TEE
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Encoding as TLE
+
+#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
+import System.Posix.Types (GroupID, UserID)
+#endif
+
+#if !MIN_VERSION_base(4, 8, 0)
+import Control.Applicative (Applicative (..), (<$>), (<$))
+#endif
+
+#if !MIN_VERSION_process(1, 3, 0)
+import qualified System.Process.Internals as P (createProcess_)
+#endif
+
+-- | An abstract configuration for a process, which can then be
+-- launched into an actual running 'Process'. Takes three type
+-- parameters, providing the types of standard input, standard output,
+-- and standard error, respectively.
+--
+-- There are three ways to construct a value of this type:
+--
+-- * With the 'proc' smart constructor, which takes a command name and
+-- a list of arguments.
+--
+-- * With the 'shell' smart constructor, which takes a shell string
+--
+-- * With the 'IsString' instance via OverloadedStrings. If you
+-- provide it a string with no spaces (e.g., @"date"@), it will
+-- treat it as a raw command with no arguments (e.g., @proc "date"
+-- []@). If it has spaces, it will use @shell@.
+--
+-- In all cases, the default for all three streams is to inherit the
+-- streams from the parent process. For other settings, see the
+-- [setters below](#processconfigsetters) for default values.
+--
+-- Once you have a @ProcessConfig@ you can launch a process from it
+-- using the functions in the section [Launch a
+-- process](#launchaprocess).
+--
+-- @since 0.1.0.0
+data ProcessConfig stdin stdout stderr = ProcessConfig
+    { pcCmdSpec :: !P.CmdSpec
+    , pcStdin :: !(StreamSpec 'STInput stdin)
+    , pcStdout :: !(StreamSpec 'STOutput stdout)
+    , pcStderr :: !(StreamSpec 'STOutput stderr)
+    , pcWorkingDir :: !(Maybe FilePath)
+    , pcEnv :: !(Maybe [(String, String)])
+    , pcCloseFds :: !Bool
+    , pcCreateGroup :: !Bool
+    , pcDelegateCtlc :: !Bool
+
+#if MIN_VERSION_process(1, 3, 0)
+    , pcDetachConsole :: !Bool
+    , pcCreateNewConsole :: !Bool
+    , pcNewSession :: !Bool
+#endif
+
+#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
+    , pcChildGroup :: !(Maybe GroupID)
+    , pcChildUser :: !(Maybe UserID)
+#endif
+    }
+instance Show (ProcessConfig stdin stdout stderr) where
+    show pc = concat
+        [ case pcCmdSpec pc of
+            P.ShellCommand s -> "Shell command: " ++ s
+            P.RawCommand x xs -> "Raw command: " ++ unwords (map escape (x:xs))
+        , "\n"
+        , case pcWorkingDir pc of
+            Nothing -> ""
+            Just wd -> concat
+                [ "Run from: "
+                , wd
+                , "\n"
+                ]
+        , case pcEnv pc of
+            Nothing -> ""
+            Just e -> unlines
+                $ "Modified environment:"
+                : map (\(k, v) -> concat [k, "=", v]) e
+        ]
+      where
+        escape x
+            | any (`elem` " \\\"'") x = show x
+            | x == "" = "\"\""
+            | otherwise = x
+instance (stdin ~ (), stdout ~ (), stderr ~ ())
+  => IsString (ProcessConfig stdin stdout stderr) where
+    fromString s
+        | any (== ' ') s = shell s
+        | otherwise = proc s []
+
+-- | Whether a stream is an input stream or output stream. Note that
+-- this is from the perspective of the /child process/, so that a
+-- child's standard input stream is an @STInput@, even though the
+-- parent process will be writing to it.
+--
+-- @since 0.1.0.0
+data StreamType = STInput | STOutput
+
+-- | A specification for how to create one of the three standard child
+-- streams, @stdin@, @stdout@ and @stderr@. A 'StreamSpec' can be
+-- thought of as containing
+--
+-- 1. A type safe version of 'P.StdStream' from "System.Process".
+-- This determines whether the stream should be inherited from the
+-- parent process, piped to or from a 'Handle', etc.
+--
+-- 2. A means of accessing the stream as a value of type @a@
+--
+-- 3. A cleanup action which will be run on the stream once the
+-- process terminates
+--
+-- To create a @StreamSpec@ see the section [Stream
+-- specs](#streamspecs).
+--
+-- @since 0.1.0.0
+data StreamSpec (streamType :: StreamType) a = StreamSpec
+    { ssStream :: !(forall b. (P.StdStream -> IO b) -> IO b)
+    , ssCreate :: !(ProcessConfig () () () -> Maybe Handle -> Cleanup a)
+    }
+    deriving Functor
+
+-- | This instance uses 'byteStringInput' to convert a raw string into
+-- a stream of input for a child process.
+--
+-- @since 0.1.0.0
+instance (streamType ~ 'STInput, res ~ ())
+  => IsString (StreamSpec streamType res) where
+    fromString = byteStringInput . fromString
+
+-- | Internal type, to make for easier composition of cleanup actions.
+--
+-- @since 0.1.0.0
+newtype Cleanup a = Cleanup { runCleanup :: IO (a, IO ()) }
+    deriving Functor
+instance Applicative Cleanup where
+    pure x = Cleanup (return (x, return ()))
+    Cleanup f <*> Cleanup x = Cleanup $ do
+        (f', c1) <- f
+        (`onException` c1) $ do
+            (x', c2) <- x
+            return (f' x', c1 `finally` c2)
+
+-- | Internal helper
+defaultProcessConfig :: ProcessConfig () () ()
+defaultProcessConfig = ProcessConfig
+    { pcCmdSpec = P.ShellCommand ""
+    , pcStdin = inherit
+    , pcStdout = inherit
+    , pcStderr = inherit
+    , pcWorkingDir = Nothing
+    , pcEnv = Nothing
+    , pcCloseFds = False
+    , pcCreateGroup = False
+    , pcDelegateCtlc = False
+
+#if MIN_VERSION_process(1, 3, 0)
+    , pcDetachConsole = False
+    , pcCreateNewConsole = False
+    , pcNewSession = False
+#endif
+
+#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
+    , pcChildGroup = Nothing
+    , pcChildUser = Nothing
+#endif
+    }
+
+-- | Create a 'ProcessConfig' from the given command and arguments.
+--
+-- @since 0.1.0.0
+proc :: FilePath -> [String] -> ProcessConfig () () ()
+proc cmd args = setProc cmd args defaultProcessConfig
+
+-- | Internal helper
+setProc :: FilePath -> [String]
+        -> ProcessConfig stdin stdout stderr
+        -> ProcessConfig stdin stdout stderr
+setProc cmd args p = p { pcCmdSpec = P.RawCommand cmd args }
+
+-- | Create a 'ProcessConfig' from the given shell command.
+--
+-- @since 0.1.0.0
+shell :: String -> ProcessConfig () () ()
+shell cmd = setShell cmd defaultProcessConfig
+
+-- | Internal helper
+setShell :: String
+         -> ProcessConfig stdin stdout stderr
+         -> ProcessConfig stdin stdout stderr
+setShell cmd p = p { pcCmdSpec = P.ShellCommand cmd }
+
+-- | Set the child's standard input stream to the given 'StreamSpec'.
+--
+-- Default: 'inherit'
+--
+-- @since 0.1.0.0
+setStdin :: StreamSpec 'STInput stdin
+         -- ^ 
+         -> ProcessConfig stdin0 stdout stderr
+         -- ^ 
+         -> ProcessConfig stdin stdout stderr
+setStdin spec pc = pc { pcStdin = spec }
+
+-- | Set the child's standard output stream to the given 'StreamSpec'.
+--
+-- Default: 'inherit'
+--
+-- @since 0.1.0.0
+setStdout :: StreamSpec 'STOutput stdout
+          -- ^ 
+          -> ProcessConfig stdin stdout0 stderr
+          -- ^ 
+          -> ProcessConfig stdin stdout stderr
+setStdout spec pc = pc { pcStdout = spec }
+
+-- | Set the child's standard error stream to the given 'StreamSpec'.
+--
+-- Default: 'inherit'
+--
+-- @since 0.1.0.0
+setStderr :: StreamSpec 'STOutput stderr
+          -- ^ 
+          -> ProcessConfig stdin stdout stderr0
+          -- ^ 
+          -> ProcessConfig stdin stdout stderr
+setStderr spec pc = pc { pcStderr = spec }
+
+-- | Set the working directory of the child process.
+--
+-- Default: current process's working directory.
+--
+-- @since 0.1.0.0
+setWorkingDir :: FilePath
+              -- ^ 
+              -> ProcessConfig stdin stdout stderr
+              -- ^ 
+              -> ProcessConfig stdin stdout stderr
+setWorkingDir dir pc = pc { pcWorkingDir = Just dir }
+
+-- | Inherit the working directory from the parent process.
+--
+-- @since 0.2.2.0
+setWorkingDirInherit
+  :: ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> ProcessConfig stdin stdout stderr
+setWorkingDirInherit pc = pc { pcWorkingDir = Nothing }
+
+-- | Set the environment variables of the child process.
+--
+-- Default: current process's environment.
+--
+-- @since 0.1.0.0
+setEnv :: [(String, String)]
+       -- ^ 
+       -> ProcessConfig stdin stdout stderr
+       -- ^ 
+       -> ProcessConfig stdin stdout stderr
+setEnv env pc = pc { pcEnv = Just env }
+
+-- | Inherit the environment variables from the parent process.
+--
+-- @since 0.2.2.0
+setEnvInherit
+  :: ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> ProcessConfig stdin stdout stderr
+setEnvInherit pc = pc { pcEnv = Nothing }
+
+-- | Should we close all file descriptors besides stdin, stdout, and
+-- stderr? See 'P.close_fds' for more information.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setCloseFds
+    :: Bool
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setCloseFds x pc = pc { pcCloseFds = x }
+
+-- | Should we create a new process group?
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setCreateGroup
+    :: Bool
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setCreateGroup x pc = pc { pcCreateGroup = x }
+
+-- | Delegate handling of Ctrl-C to the child. For more information,
+-- see 'P.delegate_ctlc'.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setDelegateCtlc
+    :: Bool
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setDelegateCtlc x pc = pc { pcDelegateCtlc = x }
+
+#if MIN_VERSION_process(1, 3, 0)
+
+-- | Detach console on Windows, see 'P.detach_console'.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setDetachConsole
+    :: Bool
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setDetachConsole x pc = pc { pcDetachConsole = x }
+
+-- | Create new console on Windows, see 'P.create_new_console'.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setCreateNewConsole
+    :: Bool
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setCreateNewConsole x pc = pc { pcCreateNewConsole = x }
+
+-- | Set a new session with the POSIX @setsid@ syscall, does nothing
+-- on non-POSIX. See 'P.new_session'.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setNewSession
+    :: Bool
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setNewSession x pc = pc { pcNewSession = x }
+#endif
+
+#if MIN_VERSION_process(1, 4, 0) && !WINDOWS
+-- | Set the child process's group ID with the POSIX @setgid@ syscall,
+-- does nothing on non-POSIX. See 'P.child_group'.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setChildGroup
+    :: GroupID
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setChildGroup x pc = pc { pcChildGroup = Just x }
+
+-- | Inherit the group from the parent process.
+--
+-- @since 0.2.2.0
+setChildGroupInherit
+  :: ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> ProcessConfig stdin stdout stderr
+setChildGroupInherit pc = pc { pcChildGroup = Nothing }
+
+-- | Set the child process's user ID with the POSIX @setuid@ syscall,
+-- does nothing on non-POSIX. See 'P.child_user'.
+--
+-- Default: False
+--
+-- @since 0.1.0.0
+setChildUser
+    :: UserID
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+    -- ^ 
+    -> ProcessConfig stdin stdout stderr
+setChildUser x pc = pc { pcChildUser = Just x }
+
+-- | Inherit the user from the parent process.
+--
+-- @since 0.2.2.0
+setChildUserInherit
+  :: ProcessConfig stdin stdout stderr
+  -- ^ 
+  -> ProcessConfig stdin stdout stderr
+setChildUserInherit pc = pc { pcChildUser = Nothing }
+#endif
+
+-- | Create a new 'StreamSpec' from the given 'P.StdStream' and a
+-- helper function. This function:
+--
+-- * Takes as input the raw @Maybe Handle@ returned by the
+-- 'P.createProcess' function. The handle will be @Just@ 'Handle' if the
+-- 'P.StdStream' argument is 'P.CreatePipe' and @Nothing@ otherwise.
+-- See 'P.createProcess' for more details.
+--
+-- * Returns the actual stream value @a@, as well as a cleanup
+-- function to be run when calling 'stopProcess'.
+--
+-- If making a 'StreamSpec' with 'P.CreatePipe', prefer 'mkPipeStreamSpec',
+-- which encodes the invariant that a 'Handle' is created.
+--
+-- @since 0.1.0.0
+mkStreamSpec :: P.StdStream
+             -- ^ 
+             -> (ProcessConfig () () () -> Maybe Handle -> IO (a, IO ()))
+             -- ^ 
+             -> StreamSpec streamType a
+mkStreamSpec ss f = mkManagedStreamSpec ($ ss) f
+
+-- | Create a new 'P.CreatePipe' 'StreamSpec' from the given function.
+-- This function:
+--
+-- * Takes as input the @Handle@ returned by the 'P.createProcess' function.
+-- See 'P.createProcess' for more details.
+--
+-- * Returns the actual stream value @a@, as well as a cleanup
+-- function to be run when calling 'stopProcess'.
+--
+-- @since 0.2.10.0
+mkPipeStreamSpec :: (ProcessConfig () () () -> Handle -> IO (a, IO ()))
+                 -- ^ 
+                 -> StreamSpec streamType a
+                 -- ^ 
+mkPipeStreamSpec f = mkStreamSpec P.CreatePipe $ \pc mh ->
+    case mh of
+        Just h -> f pc h
+        Nothing -> error "Invariant violation: making StreamSpec with CreatePipe unexpectedly did not return a Handle"
+
+-- | Create a new 'StreamSpec' from a function that accepts a
+-- 'P.StdStream' and a helper function.  This function is the same as
+-- the helper in 'mkStreamSpec'
+mkManagedStreamSpec :: (forall b. (P.StdStream -> IO b) -> IO b)
+                    -- ^ 
+                    -> (ProcessConfig () () () -> Maybe Handle -> IO (a, IO ()))
+                    -- ^ 
+                    -> StreamSpec streamType a
+mkManagedStreamSpec ss f = StreamSpec ss (\pc mh -> Cleanup (f pc mh))
+
+-- | A stream spec which simply inherits the stream of the parent
+-- process.
+--
+-- @since 0.1.0.0
+inherit :: StreamSpec anyStreamType ()
+inherit = mkStreamSpec P.Inherit (\_ _ -> pure ((), return ()))
+
+-- | A stream spec which is empty when used for for input and discards
+-- output.  Note this requires your platform's null device to be
+-- available when the process is started.
+--
+-- @since 0.2.5.0
+nullStream :: StreamSpec anyStreamType ()
+nullStream = mkManagedStreamSpec opener cleanup
+  where
+    opener f =
+      withBinaryFile nullDevice ReadWriteMode $ \handle ->
+        f (P.UseHandle handle)
+    cleanup _ _ =
+      pure ((), return ())
+
+-- | A stream spec which will close the stream for the child process.
+-- You usually do not want to use this, as it will leave the
+-- corresponding file descriptor unassigned and hence available for
+-- re-use in the child process.  Prefer 'nullStream' unless you're
+-- certain you want this behavior.
+--
+-- @since 0.1.0.0
+closed :: StreamSpec anyStreamType ()
+#if MIN_VERSION_process(1, 4, 0)
+closed = mkStreamSpec P.NoStream (\_ _ -> pure ((), return ()))
+#else
+closed = mkPipeStreamSpec (\_ h -> ((), return ()) <$ hClose h)
+#endif
+
+-- | An input stream spec which sets the input to the given
+-- 'L.ByteString'. A separate thread will be forked to write the
+-- contents to the child process.
+--
+-- @since 0.1.0.0
+byteStringInput :: L.ByteString -> StreamSpec 'STInput ()
+byteStringInput lbs = mkPipeStreamSpec $ \_ h -> do
+    void $ async $ do
+        L.hPut h lbs
+        hClose h
+    return ((), hClose h)
+
+-- | Capture the output of a process in a 'L.ByteString'.
+--
+-- This function will fork a separate thread to consume all input from
+-- the process, and will only make the results available when the
+-- underlying 'Handle' is closed. As this is provided as an 'STM'
+-- action, you can either check if the result is available, or block
+-- until it's ready.
+--
+-- In the event of any exception occurring when reading from the
+-- 'Handle', the 'STM' action will throw a
+-- 'ByteStringOutputException'.
+--
+-- @since 0.1.0.0
+byteStringOutput :: StreamSpec 'STOutput (STM L.ByteString)
+byteStringOutput = mkPipeStreamSpec $ \pc h -> byteStringFromHandle pc h
+
+-- | Helper function (not exposed) for both 'byteStringOutput' and
+-- 'withProcessInterleave'. This will consume all of the output from
+-- the given 'Handle' in a separate thread and provide access to the
+-- resulting 'L.ByteString' via STM. Second action will close the
+-- reader handle.
+byteStringFromHandle
+  :: ProcessConfig () () ()
+  -> Handle -- ^ reader handle
+  -> IO (STM L.ByteString, IO ())
+byteStringFromHandle pc h = do
+    mvar <- newEmptyTMVarIO
+
+    void $ async $ do
+        let loop front = do
+                bs <- S.hGetSome h defaultChunkSize
+                if S.null bs
+                    then atomically $ putTMVar mvar $ Right $ L.fromChunks $ front []
+                    else loop $ front . (bs:)
+        loop id `catch` \e -> do
+            atomically $ void $ tryPutTMVar mvar $ Left $ ByteStringOutputException e pc
+            throwIO e
+
+    return (readTMVar mvar >>= either throwSTM return, hClose h)
+
+-- | Create a new pipe between this process and the child, and return
+-- a 'Handle' to communicate with the child.
+--
+-- @since 0.1.0.0
+createPipe :: StreamSpec anyStreamType Handle
+createPipe = mkPipeStreamSpec $ \_ h -> return (h, hClose h)
+
+-- | Use the provided 'Handle' for the child process, and when the
+-- process exits, do /not/ close it. This is useful if, for example,
+-- you want to have multiple processes write to the same log file
+-- sequentially.
+--
+-- @since 0.1.0.0
+useHandleOpen :: Handle -> StreamSpec anyStreamType ()
+useHandleOpen h = mkStreamSpec (P.UseHandle h) $ \_ _ -> return ((), return ())
+
+-- | Use the provided 'Handle' for the child process, and when the
+-- process exits, close it. If you have no reason to keep the 'Handle'
+-- open, you should use this over 'useHandleOpen'.
+--
+-- @since 0.1.0.0
+useHandleClose :: Handle -> StreamSpec anyStreamType ()
+useHandleClose h = mkStreamSpec (P.UseHandle h) $ \_ _ -> return ((), hClose h)
+
+-- | Exception thrown by 'System.Process.Typed.checkExitCode' in the event of a
+-- non-success exit code. Note that 'System.Process.Typed.checkExitCode' is
+-- called by other functions as well, like 'System.Process.Typed.runProcess_'
+-- or 'System.Process.Typed.readProcess_'.
+--
+-- Note that several functions that throw an 'ExitCodeException' intentionally do not populate 'eceStdout' or 'eceStderr'.
+-- This prevents unbounded memory usage for large stdout and stderrs.
+--
+-- Functions which do include 'eceStdout' or 'eceStderr' (like
+-- 'System.Process.Typed.readProcess_') state so in their documentation.
+--
+-- @since 0.1.0.0
+data ExitCodeException = ExitCodeException
+    { eceExitCode :: ExitCode
+    , eceProcessConfig :: ProcessConfig () () ()
+    , eceStdout :: L.ByteString
+    , eceStderr :: L.ByteString
+    }
+    deriving Typeable
+instance Exception ExitCodeException
+instance Show ExitCodeException where
+    show ece = concat
+        [ "Received "
+        , show (eceExitCode ece)
+        , " when running\n"
+        -- Too much output for an exception if we show the modified
+        -- environment, so hide it
+        , show (eceProcessConfig ece) { pcEnv = Nothing }
+        , if L.null (eceStdout ece)
+            then ""
+            else "Standard output:\n\n" ++ unpack (eceStdout ece)
+        , if L.null (eceStderr ece)
+            then ""
+            else "Standard error:\n\n" ++ unpack (eceStderr ece)
+        ]
+      where
+        -- Format with UTF-8, because we have to choose some encoding,
+        -- and UTF-8 is the least likely to be wrong in general.
+        unpack = TL.unpack . TLE.decodeUtf8With TEE.lenientDecode
+
+-- | Wrapper for when an exception is thrown when reading from a child
+-- process, used by 'byteStringOutput'.
+--
+-- @since 0.1.0.0
+data ByteStringOutputException = ByteStringOutputException SomeException (ProcessConfig () () ())
+    deriving (Show, Typeable)
+instance Exception ByteStringOutputException
+
+bracket :: MonadUnliftIO m => IO a -> (a -> IO b) -> (a -> m c) -> m c
+bracket before after thing = withRunInIO $ \run -> E.bracket before after (run . thing)
+
+finally :: MonadUnliftIO m => m a -> IO () -> m a
+finally thing after = withRunInIO $ \run -> E.finally (run thing) after
+
+-- | The name of the system null device
+nullDevice :: FilePath
+#if WINDOWS
+nullDevice = "\\\\.\\NUL"
+#else
+nullDevice = "/dev/null"
+#endif
diff --git a/test/System/Process/TypedSpec.hs b/test/System/Process/TypedSpec.hs
--- a/test/System/Process/TypedSpec.hs
+++ b/test/System/Process/TypedSpec.hs
@@ -3,6 +3,7 @@
 module System.Process.TypedSpec (spec) where
 
 import System.Process.Typed
+import System.Process.Typed.Internal
 import System.IO
 import Control.Concurrent.Async (Concurrently (..))
 import Control.Concurrent.STM (atomically)
@@ -21,12 +22,36 @@
 
 spec :: Spec
 spec = do
+    -- This is mainly to make sure we use the right device filename on Windows
+    it "Null device is accessible" $ do
+        withBinaryFile nullDevice WriteMode $ \fp -> do
+          hPutStrLn fp "Hello world"
+        withBinaryFile nullDevice ReadMode $ \fp -> do
+          atEnd <- hIsEOF fp
+          atEnd `shouldBe` True
+
     it "bytestring stdin" $ do
         let bs :: IsString s => s
             bs = "this is a test"
         res <- readProcess (setStdin bs "cat")
         res `shouldBe` (ExitSuccess, bs, "")
 
+    it "null stdin" $ do
+        res <- readProcess (setStdin nullStream "cat")
+        res `shouldBe` (ExitSuccess, "", "")
+
+    it "null stdout" $ do
+        -- In particular, writing to that doesn't terminate the process with an error
+        bs <- readProcessStderr_ $ setStdout nullStream $ setStdin nullStream $
+          proc "sh" ["-c", "echo hello; echo world >&2"]
+        bs `shouldBe` "world\n"
+
+    it "null stderr" $ do
+        -- In particular, writing to that doesn't terminate the process with an error
+        bs <- readProcessStdout_ $ setStderr nullStream $ setStdin nullStream $
+          proc "sh" ["-c", "echo hello >&2; echo world"]
+        bs `shouldBe` "world\n"
+
     it "useHandleOpen" $ withSystemTempFile "use-handle-open" $ \fp h -> do
         let bs :: IsString s => s
             bs = "this is a test 2"
@@ -74,7 +99,7 @@
         runProcess_ "false" `shouldThrow` \ExitCodeException{} -> True
 
     it "async" $ withSystemTempFile "httpbin" $ \fp h -> do
-        lbs <- withProcess (setStdin createPipe $ setStdout byteStringOutput "base64") $ \p ->
+        lbs <- withProcessWait (setStdin createPipe $ setStdout byteStringOutput "base64") $ \p ->
             runConcurrently $
                 Concurrently (do
                   bs <- S.readFile "README.md"
@@ -86,3 +111,62 @@
         let encoded = S.filter (/= 10) $ L.toStrict lbs
         raw <- S.readFile fp
         encoded `shouldBe` B64.encode raw
+
+    describe "withProcessWait" $
+        it "succeeds with sleep" $ do
+          p <- withProcessWait (proc "sleep" ["1"]) pure
+          checkExitCode p :: IO ()
+
+    describe "withProcessWait_" $
+        it "succeeds with sleep"
+           ((withProcessWait_ (proc "sleep" ["1"]) $ const $ pure ()) :: IO ())
+
+    -- These tests fail on older GHCs/process package versions
+    -- because, apparently, waitForProcess isn't interruptible. See
+    -- https://github.com/fpco/typed-process/pull/26#issuecomment-505702573.
+
+    {-
+    describe "withProcessTerm" $ do
+        it "fails with sleep" $ do
+          p <- withProcessTerm (proc "sleep" ["1"]) pure
+          checkExitCode p `shouldThrow` anyException
+
+    describe "withProcessTerm_" $ do
+        it "fails with sleep" $
+          withProcessTerm_ (proc "sleep" ["1"]) (const $ pure ())
+          `shouldThrow` anyException
+    -}
+
+    it "interleaved output" $ withSystemTempFile "interleaved-output" $ \fp h -> do
+        S.hPut h "\necho 'stdout'\n>&2 echo 'stderr'\necho 'stdout'"
+        hClose h
+
+        let config = proc "sh" [fp]
+        -- Assert, that our bash script doesn't send output only to stdout and
+        -- we assume that we captured from stderr as well
+        onlyErr <- readProcessStderr_ (setStdout createPipe config)
+        onlyErr `shouldBe` "stderr\n"
+
+        (res, lbs1) <- readProcessInterleaved config
+        res `shouldBe` ExitSuccess
+        lbs1 `shouldBe` "stdout\nstderr\nstdout\n"
+
+        lbs2 <- readProcessInterleaved_ config
+        lbs1 `shouldBe` lbs2
+
+    it "interleaved output handles large data" $ withSystemTempFile "interleaved-output" $ \fp h -> do
+        S.hPut h "\nfor i in {1..4064}; do\necho 'stdout';\n>&2 echo 'stderr';\necho 'stdout';\ndone"
+        hClose h
+
+        let config = proc "sh" [fp]
+        (result, lbs1) <- readProcessInterleaved config
+        result `shouldBe` ExitSuccess
+        lbs2 <- readProcessInterleaved_ config
+        lbs1 `shouldBe` lbs2
+
+        let expected = "stdout\nstderr\nstdout\n"
+        L.take (L.length expected) lbs1 `shouldBe` expected
+
+    it "empty param are showed" $
+      let expected = "Raw command: podman exec --detach-keys \"\" ctx bash\n"
+       in show (proc "podman" ["exec", "--detach-keys", "", "ctx", "bash"]) `shouldBe` expected
diff --git a/typed-process.cabal b/typed-process.cabal
--- a/typed-process.cabal
+++ b/typed-process.cabal
@@ -1,17 +1,15 @@
-cabal-version: >= 1.10
+cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.29.0.
+-- This file has been generated from package.yaml by hpack version 0.38.0.
 --
 -- see: https://github.com/sol/hpack
---
--- hash: 8fd30ba42322fffadc01326bcc1370fa86c650057800ca91d5381d89e91df797
 
 name:           typed-process
-version:        0.2.3.0
+version:        0.2.13.0
 synopsis:       Run external processes, with strong typing of streams
-description:    Please see the tutorial at <https://haskell-lang.org/library/typed-process>
+description:    Please see the tutorial at <https://github.com/fpco/typed-process#readme>
 category:       System
-homepage:       https://haskell-lang.org/library/typed-process
+homepage:       https://github.com/fpco/typed-process
 bug-reports:    https://github.com/fpco/typed-process/issues
 author:         Michael Snoyman
 maintainer:     michael@snoyman.com
@@ -19,8 +17,8 @@
 license-file:   LICENSE
 build-type:     Simple
 extra-source-files:
-    ChangeLog.md
     README.md
+    ChangeLog.md
 
 source-repository head
   type: git
@@ -29,20 +27,23 @@
 library
   exposed-modules:
       System.Process.Typed
+      System.Process.Typed.Internal
   other-modules:
       Paths_typed_process
   hs-source-dirs:
       src
   build-depends:
-      async
-    , base >=4.8 && <5
+      async >=2.0
+    , base >=4.12 && <5
     , bytestring
     , process >=1.2
     , stm
+    , text
     , transformers
+    , unliftio-core
+  default-language: Haskell2010
   if os(windows)
     cpp-options: -DWINDOWS
-  default-language: Haskell2010
 
 test-suite typed-process-test
   type: exitcode-stdio-1.0
@@ -53,17 +54,21 @@
   hs-source-dirs:
       test
   ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:
+      hspec-discover:hspec-discover
   build-depends:
-      async
-    , base >=4.8 && <5
+      async >=2.0
+    , base >=4.12 && <5
     , base64-bytestring
     , bytestring
-    , hspec
+    , hspec ==2.*
     , process >=1.2
     , stm
     , temporary
+    , text
     , transformers
     , typed-process
+    , unliftio-core
   default-language: Haskell2010
 
 test-suite typed-process-test-single-threaded
@@ -74,15 +79,19 @@
       Paths_typed_process
   hs-source-dirs:
       test
+  build-tool-depends:
+      hspec-discover:hspec-discover
   build-depends:
-      async
-    , base >=4.8 && <5
+      async >=2.0
+    , base >=4.12 && <5
     , base64-bytestring
     , bytestring
-    , hspec
+    , hspec ==2.*
     , process >=1.2
     , stm
     , temporary
+    , text
     , transformers
     , typed-process
+    , unliftio-core
   default-language: Haskell2010
