diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,17 @@
 # ChangeLog
 
+## 0.3.8
+
+* Tell a stream that was reset from one that ended.  After a
+  RESET_STREAM, `recvStream` returns an empty ByteString, just as it does
+  at the end of a stream, and nothing else said which it was.  An
+  application protocol may have to know: HTTP/3's QPACK decoder has to
+  send a Stream Cancellation for a request stream that was reset
+  (RFC 9204 Sec 4.4.2), and a reset that lands between two frames looked
+  to it exactly like the end of the request.  `resetReceived` answers the
+  error code of the peer's RESET_STREAM, or `Nothing` if there was none.
+  [#123](https://github.com/kazu-yamamoto/quic/pull/123)
+
 ## 0.3.7
 
 * Don't open a closed stream again for a late copy of its data.  A STREAM
diff --git a/Network/QUIC.hs b/Network/QUIC.hs
--- a/Network/QUIC.hs
+++ b/Network/QUIC.hs
@@ -32,6 +32,7 @@
 
     -- * IO
     recvStream,
+    resetReceived,
     sendStream,
     sendStreamMany,
     sendDatagram,
diff --git a/Network/QUIC/Receiver.hs b/Network/QUIC/Receiver.hs
--- a/Network/QUIC/Receiver.hs
+++ b/Network/QUIC/Receiver.hs
@@ -277,6 +277,9 @@
         Nothing -> return ()
         Just strm -> do
             onResetStreamReceived (connHooks conn) strm aerr
+            -- Before the pseudo FIN below, so that whoever reads it can
+            -- tell it was not a real one.
+            setResetReceived strm aerr
             setTxStreamClosed strm
             setRxStreamClosed strm
             delStream conn strm
diff --git a/Network/QUIC/Stream.hs b/Network/QUIC/Stream.hs
--- a/Network/QUIC/Stream.hs
+++ b/Network/QUIC/Stream.hs
@@ -20,6 +20,8 @@
     getRxStreamOffset,
     isRxStreamClosed,
     setRxStreamClosed,
+    resetReceived,
+    setResetReceived,
     readStreamFlowTx,
     addTxStreamData,
     setTxMaxStreamData,
diff --git a/Network/QUIC/Stream/Misc.hs b/Network/QUIC/Stream/Misc.hs
--- a/Network/QUIC/Stream/Misc.hs
+++ b/Network/QUIC/Stream/Misc.hs
@@ -9,6 +9,8 @@
     getRxStreamOffset,
     isRxStreamClosed,
     setRxStreamClosed,
+    resetReceived,
+    setResetReceived,
     --
     readStreamFlowTx,
     addTxStreamData,
@@ -25,6 +27,7 @@
 import Network.QUIC.Imports
 import Network.QUIC.Stream.Queue
 import Network.QUIC.Stream.Types
+import Network.QUIC.Types (ApplicationProtocolError)
 
 ----------------------------------------------------------------
 
@@ -66,6 +69,19 @@
     putRecvStreamQ strm ""
   where
     set (StreamState off _) = StreamState off True
+
+-- | The error code of the RESET_STREAM the peer sent for this stream, if it
+--   sent one.
+--
+-- After a RESET_STREAM, 'recvStream' returns an empty 'ByteString', just as
+-- it does at the end of the stream.  This tells the two apart: HTTP/3, for
+-- one, has to tell its QPACK encoder about a request whose reset it saw
+-- (RFC 9204, section 4.4.2), and had no way to know that it was reset.
+resetReceived :: Stream -> IO (Maybe ApplicationProtocolError)
+resetReceived Stream{..} = readIORef streamResetRx
+
+setResetReceived :: Stream -> ApplicationProtocolError -> IO ()
+setResetReceived Stream{..} aerr = writeIORef streamResetRx $ Just aerr
 
 ----------------------------------------------------------------
 
diff --git a/Network/QUIC/Stream/Types.hs b/Network/QUIC/Stream/Types.hs
--- a/Network/QUIC/Stream/Types.hs
+++ b/Network/QUIC/Stream/Types.hs
@@ -41,6 +41,8 @@
     , -- input stream fragments to streamQ, and how many of them
       streamReass :: IORef (Int, Skew RxStreamData)
     , streamSyncFinTx :: MVar ()
+    , streamResetRx :: IORef (Maybe ApplicationProtocolError)
+    -- ^ The error code of a RESET_STREAM from the peer
     }
 
 instance Show Stream where
@@ -56,6 +58,7 @@
     streamRecvQ     <- newRecvStreamQ
     streamReass     <- newIORef (0, Skew.empty)
     streamSyncFinTx <- newEmptyMVar
+    streamResetRx   <- newIORef Nothing
     return Stream{..}
 {- FOURMOLU_ENABLE -}
 
diff --git a/quic.cabal b/quic.cabal
--- a/quic.cabal
+++ b/quic.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.0
 name:               quic
-version:            0.3.7
+version:            0.3.8
 license:            BSD3
 license-file:       LICENSE
 maintainer:         kazu@iij.ad.jp
diff --git a/test/Config.hs b/test/Config.hs
--- a/test/Config.hs
+++ b/test/Config.hs
@@ -292,7 +292,12 @@
     shouldDelay (DelayServerPacket k) fromC pn = not fromC && pn == k
     shouldDelay _ _ _ = False
     -- The packets that follow go ahead of the one held back.
-    delayIf True send_ = void $ forkIO $ threadDelay 1000000 >> send_
+    --
+    -- Long enough for the data to be sent again and the stream closed before
+    -- it lands, which is what the test is about, and no longer: it was a
+    -- second, and a second is two and a half of these tests' worth of
+    -- everything else.
+    delayIf True send_ = void $ forkIO $ threadDelay delayTime >> send_
     delayIf False send_ = send_
 
 chooseALPN :: Version -> [ByteString] -> IO ByteString
@@ -328,3 +333,7 @@
             Just (s, d)
                 | s == sid -> return $ Just d
                 | otherwise -> return Nothing
+
+-- | How long 'DelayClientPacket' and 'DelayServerPacket' hold a datagram.
+delayTime :: Int
+delayTime = 100000
diff --git a/test/IOSpec.hs b/test/IOSpec.hs
--- a/test/IOSpec.hs
+++ b/test/IOSpec.hs
@@ -100,6 +100,8 @@
         -- number of bytes sent by the RESET_STREAM sender.
         it "sends RESET_STREAM with the bytes sent as final size" $ do
             withPipe (DropClientPacket []) $ testResetStreamFinalSize cc sc waitS
+        it "tells a stream that was reset from one that ended" $ do
+            withPipe (DropClientPacket []) $ testResetReceived cc sc waitS
     describe "closed stream" $ do
         it "ignores a late copy of the data it received on a stream it opened" $ do
             withPipe (DelayServerPacket 300) $ testLateCopy False cc sc waitS
@@ -179,7 +181,7 @@
         C.run cc $ \conn -> do
             exchange conn
             -- The copy arrives while we wait.
-            threadDelay 1500000
+            threadDelay 200000
             exchange conn
     exchange conn = do
         strm <- stream conn
@@ -195,6 +197,43 @@
         assertEndOfStream strm
         sendStream strm (BS.replicate downLen 0)
         closeStream strm
+
+-- | After a RESET_STREAM, 'recvStream' returns "" just as at the end of the
+-- stream; 'resetReceived' tells the two apart.  The client ends one stream
+-- and resets another, and the server looks at both.
+testResetReceived :: C.ClientConfig -> ServerConfig -> IO () -> IO ()
+testResetReceived cc sc waitS = do
+    resultVar <- newEmptyMVar
+    E.bracket (forkIO $ server resultVar) killThread $ \_ -> client resultVar
+  where
+    aerr = ApplicationProtocolError 7
+
+    client resultVar = do
+        waitS
+        C.run cc $ \conn -> do
+            strm0 <- stream conn
+            sendStream strm0 "ended"
+            shutdownStream strm0
+            strm1 <- stream conn
+            sendStream strm1 "reset"
+            -- Let the data go first.
+            threadDelay 100000
+            resetStream strm1 aerr
+            mres <- Timeout.timeout 5000000 $ takeMVar resultVar
+            mres `shouldBe` Just (Nothing, Just aerr)
+
+    server resultVar = run sc $ \conn -> do
+        strm0 <- acceptStream conn
+        strm1 <- acceptStream conn
+        let drain strm = do
+                bs <- recvStream strm 1024
+                unless (BS.null bs) $ drain strm
+        drain strm0
+        drain strm1
+        r0 <- resetReceived strm0
+        r1 <- resetReceived strm1
+        putMVar resultVar (r0, r1)
+        threadDelay 1000000
 
 testRecvStreamClientStopFirst
     :: C.ClientConfig -> ServerConfig -> IO () -> IO ()
