diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,10 @@
 # ChangeLog for http2
 
+## 5.4.12
+
+* Close a connection's streams before waiting for its threads.
+  [#232](https://github.com/kazu-yamamoto/http2/pull/232)
+
 ## 5.4.11
 
 * `allocSimpleConfigWith` is `allocSimpleConfig'` with an `STM` action beside
diff --git a/Network/HTTP2/H2/Context.hs b/Network/HTTP2/H2/Context.hs
--- a/Network/HTTP2/H2/Context.hs
+++ b/Network/HTTP2/H2/Context.hs
@@ -28,18 +28,22 @@
 
 ----------------------------------------------------------------
 
--- | Telling a connection's threads to stop and seeing them out before the
---   cleanup, which closes the streams they are still writing to.
+-- | Telling a connection's threads to stop, and seeing them out before
+--   returning to a caller which tears the connection down under them.
 --
+-- The cleanup comes first: it closes the streams, which is how a thread
+-- blocked on a request body is told the peer is gone.  Waiting for the
+-- threads before it would be waiting for what the cleanup has to say.
+--
 -- The wait is capped because one of the threads waited for runs the
--- application's handler: an application which swallows the asynchronous
--- exception would otherwise keep this from ever returning.
+-- application's handler: an application which never stops would otherwise
+-- keep this from ever returning.  It is a 'E.finally' so that the threads
+-- are seen out whatever the cleanup does.
 stopAfterAndWait
     :: T.ThreadManager -> IO a -> (Maybe E.SomeException -> IO ()) -> IO a
 stopAfterAndWait mgr action cleanup =
-    T.stopAfter mgr action $ \res -> do
-        void $ timeout stopTimeout $ T.waitUntilAllGone mgr
-        cleanup res
+    T.stopAfter mgr action $ \res ->
+        cleanup res `E.finally` void (timeout stopTimeout $ T.waitUntilAllGone mgr)
 
 -- | How long the threads are given to end after they have been told to.
 --   Unwinding takes milliseconds; this is for one that does not stop.
diff --git a/http2.cabal b/http2.cabal
--- a/http2.cabal
+++ b/http2.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.0
 name:               http2
-version:            5.4.11
+version:            5.4.12
 license:            BSD3
 license-file:       LICENSE
 maintainer:         Kazu Yamamoto <kazu@iij.ad.jp>
@@ -312,6 +312,7 @@
         network-byte-order,
         network-run >= 0.7 && <0.8,
         random,
+        time-manager >=0.5 && <0.6,
         typed-process
 
 test-suite spec2
diff --git a/test/HTTP2/ServerSpec.hs b/test/HTTP2/ServerSpec.hs
--- a/test/HTTP2/ServerSpec.hs
+++ b/test/HTTP2/ServerSpec.hs
@@ -36,6 +36,7 @@
 import System.IO
 import System.IO.Unsafe
 import System.Random
+import System.ThreadManager (KilledByThreadManager (..))
 import System.Timeout (timeout)
 import Test.Hspec
 
@@ -138,6 +139,16 @@
                 runAttack cancelInFlight
                 timeout 1000000 (takeMVar doneVar) `shouldReturn` Just ()
 
+        it "tells a handler waiting on a thread of its own that the peer is gone" $ do
+            -- The handler swallows the manager's kill and goes on waiting for
+            -- a thread which only the request body can tell, which is what
+            -- grapesy does (#231).
+            doneVar <- newEmptyMVar
+            withServer (runServerWaitingHandler doneVar) $ do
+                threadDelay 10000
+                openStreamAndDisconnect
+                timeout 2000000 (takeMVar doneVar) `shouldReturn` Just True
+
         it "survives a padded HEADERS whose padding covers the priority fields" $
             withServer runServer $ do
                 threadDelay 10000
@@ -783,6 +794,41 @@
         threadDelay 200000
         _ <- sendResponse responseHello []
         putMVar doneVar ()
+
+-- | A server whose handler leaves the work to a thread of its own and goes on
+-- waiting for it when the manager kills it, as grapesy's does.  Nothing but
+-- the request body tells that thread that the peer is gone.
+runServerWaitingHandler :: MVar Bool -> ServerSettings -> IO ()
+runServerWaitingHandler doneVar set =
+    runTCPServerWithSettings set (Just host) port runHTTP2Server
+  where
+    runHTTP2Server s =
+        E.bracket
+            (allocSimpleConfig s 32768)
+            freeSimpleConfig
+            (\conf -> run defaultServerConfig conf waitingServer)
+    waitingServer req _aux _sendResponse = do
+        told <- newEmptyMVar
+        let readAll = do
+                bs <- getRequestBodyChunk req
+                if B.null bs then return False else readAll
+            wasTold :: Either E.SomeException Bool -> Bool
+            wasTold = either (const True) id
+        _ <- forkIO $ E.try readAll >>= putMVar told . wasTold
+        takeMVarIgnoringKill told >>= putMVar doneVar
+
+-- | Taking an 'MVar', ignoring the manager's kill as grapesy's handler does.
+takeMVarIgnoringKill :: MVar a -> IO a
+takeMVarIgnoringKill var =
+    takeMVar var `E.catch` \(KilledByThreadManager _) -> takeMVarIgnoringKill var
+
+-- | Open a stream the server waits for a body on, then drop the connection.
+openStreamAndDisconnect :: IO ()
+openStreamAndDisconnect = runTCPClient host port $ \s -> do
+    sendAll s connectionPreface
+    sendAll s $ encodeFrame (EncodeInfo defaultFlags 0 Nothing) $ SettingsFrame []
+    sendAll s $ openStreamFrame 1
+    threadDelay 100000
 
 runFakeServer :: MVar ByteString -> ServerSettings -> IO ()
 runFakeServer prefaceVar set = do
