diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,77 +1,104 @@
 # Revision history for http3
 
+## 0.1.8
+
+* Allow time-manager 0.4.
+* Keep the encoder's dynamic table in step when Huffman makes a field longer.
+  [#43](https://github.com/kazu-yamamoto/http3/pull/43)
+* Decode an encoder instruction once it has all arrived.
+  [#44](https://github.com/kazu-yamamoto/http3/pull/44)
+* Do not take a body cut short for a whole one.
+  [#45](https://github.com/kazu-yamamoto/http3/pull/45)
+* Reset a client request cut short instead of ending it.
+  [#46](https://github.com/kazu-yamamoto/http3/pull/46)
+* Take the final response past informational ones.
+  [#47](https://github.com/kazu-yamamoto/http3/pull/47)
+* Accept a content-length on a response with no content.
+  [#48](https://github.com/kazu-yamamoto/http3/pull/48)
+* Reset the stream of a failed handler with H3_INTERNAL_ERROR.
+  [#49](https://github.com/kazu-yamamoto/http3/pull/49)
+* Evict from the decoder's table by size, and refuse an entry too large.
+  [#50](https://github.com/kazu-yamamoto/http3/pull/50)
+* Tests: keep the cases that need /drain out of h3ErrorSpec.
+  [#42](https://github.com/kazu-yamamoto/http3/pull/42)
+
 ## 0.1.7
 
-* Requiring quic v0.3.10, which hands the application a stream only once
-  the frame that opened it has been checked.  A server used to answer on
-  a stream whose first frame was past the flow control limit, just before
-  the connection was closed over it.
-* Closing the connection with H3_CLOSED_CRITICAL_STREAM when a write on
-  one of our QPACK streams finds it closed, which the peer's STOP_SENDING
-  does.  It used to throw `StreamIsClosed` into whichever thread wrote.
-* QPACK: sending Set Dynamic Table Capacity only when the capacity
-  changes.  `getMaxTableCapacity` is new.
-* h3-server: allowing more unidirectional streams than the three a client
-  needs.
-* Tests: `h3ErrorSpec` takes how to read the number of requests served,
-  so that h3spec can run it against a server elsewhere.
+* Require quic 0.3.10.
+  [#41](https://github.com/kazu-yamamoto/http3/pull/41)
+* Close the connection with H3_CLOSED_CRITICAL_STREAM when one of our
+  QPACK streams is closed.
+  [#40](https://github.com/kazu-yamamoto/http3/pull/40)
+* QPACK: send Set Dynamic Table Capacity only when the capacity changes.
+  `getMaxTableCapacity` is new.
+  [#40](https://github.com/kazu-yamamoto/http3/pull/40)
+* h3-server: allow more unidirectional streams.
+  [#39](https://github.com/kazu-yamamoto/http3/pull/39)
+* Tests: `h3ErrorSpec` can run against a server elsewhere.
+  [#38](https://github.com/kazu-yamamoto/http3/pull/38)
 
 ## 0.1.6
 
-* Requiring quic v0.3.9 and http2 v5.4.6.  quic v0.3.7 stops opening a
-  closed stream again for a late copy of its data, which closed the
-  connection with FLOW_CONTROL_ERROR now and then (#15); v0.3.8 tells a
-  stream that was reset from one that ended; v0.3.9 keeps the peer's open
-  streams within initial_max_streams, closes the sending part on
-  STOP_SENDING, and opens a stream for a RESET_STREAM that comes before
-  its data.
-* QPACK: fixing the dynamic table where the two ends disagreed: the
-  maximum number of entries, when a section is blocked, the blocked
-  streams, more than one outstanding section on a stream, and a change of
-  capacity.  Encoding a field larger than the encoder's buffers.
-* QPACK: stopping counting a blocked stream however its wait ends.
-* QPACK: evicting an entry nothing refers to once its insertion is
-  acknowledged.
-* QPACK: sending Stream Cancellation for a stream not read to its end or
-  reset by the peer, and acting on one received.
-* QPACK: inserting with a reference to a name not yet acknowledged.
-* Holding a field section to our SETTINGS_MAX_FIELD_SECTION_SIZE as it
-  decodes, not only the frame carrying it; a server answers a request
-  over it with 431.  The new `FieldSectionTooLarge` is thrown otherwise.
-* Keeping to the peer's SETTINGS_MAX_FIELD_SECTION_SIZE when sending.  The
-  new `FieldSectionTooLargeForPeer` is thrown by `sendRequest` and
-  `sendResponse` for a header section over it.
-* Reading on past a DATA frame that is empty.
-* Treating a message with a connection-specific field as malformed.  The
-  new `ConnectionSpecificField` is thrown for a response or trailers
-  carrying one.
-* Refusing a second control or QPACK stream, and noticing a QPACK stream
-  closing.  Refusing a push stream.
-* Stopping reading a unidirectional stream of an unknown type, and
-  refusing a server-initiated bidirectional stream.
-* A client whose request the server stops with STOP_SENDING no longer
-  resets the stream, so that a response sent after it is still read.
-* Closing a unidirectional stream we stop reading, so that it is given
-  back to the peer's limit.
-* This is a patch release, but `TableOperation` in `Network.QPACK` has a
-  new field, `getHeaderSize`.
+* Require quic 0.3.9 and http2 5.4.6.
+  [#21](https://github.com/kazu-yamamoto/http3/pull/21)
+  [#30](https://github.com/kazu-yamamoto/http3/pull/30)
+  [#37](https://github.com/kazu-yamamoto/http3/pull/37)
+* QPACK: fix the dynamic table where the two ends disagreed.
+  [#21](https://github.com/kazu-yamamoto/http3/pull/21)
+* QPACK: stop counting a blocked stream however its wait ends.
+  [#23](https://github.com/kazu-yamamoto/http3/pull/23)
+* QPACK: evict an unreferenced entry once its insertion is acknowledged.
+  [#24](https://github.com/kazu-yamamoto/http3/pull/24)
+* QPACK: send and act on Stream Cancellation.
+  [#26](https://github.com/kazu-yamamoto/http3/pull/26)
+  [#35](https://github.com/kazu-yamamoto/http3/pull/35)
+* QPACK: insert with a reference to a name not yet acknowledged.
+  [#32](https://github.com/kazu-yamamoto/http3/pull/32)
+* Hold a field section to our SETTINGS_MAX_FIELD_SECTION_SIZE while
+  decoding.  `FieldSectionTooLarge` is new.
+  [#28](https://github.com/kazu-yamamoto/http3/pull/28)
+* Keep to the peer's SETTINGS_MAX_FIELD_SECTION_SIZE when sending.
+  `FieldSectionTooLargeForPeer` is new.
+  [#33](https://github.com/kazu-yamamoto/http3/pull/33)
+* Read on past an empty DATA frame.
+  [#22](https://github.com/kazu-yamamoto/http3/pull/22)
+* Treat a message with a connection-specific field as malformed.
+  `ConnectionSpecificField` is new.
+  [#29](https://github.com/kazu-yamamoto/http3/pull/29)
+* Refuse a second control or QPACK stream, and a push stream.
+  [#25](https://github.com/kazu-yamamoto/http3/pull/25)
+* Stop reading a unidirectional stream of an unknown type, and refuse a
+  server-initiated bidirectional stream.
+  [#31](https://github.com/kazu-yamamoto/http3/pull/31)
+* Client: do not reset a stream the server stops with STOP_SENDING.
+  [#36](https://github.com/kazu-yamamoto/http3/pull/36)
+* Close a unidirectional stream we stop reading.
+  [#37](https://github.com/kazu-yamamoto/http3/pull/37)
+* `TableOperation` in `Network.QPACK` has a new field, `getHeaderSize`.
+  [#33](https://github.com/kazu-yamamoto/http3/pull/33)
 
 ## 0.1.5
 
-* Security fixes.  Requiring http2 v5.4.5, whose HPACK integer decoder is
-  bounded; QPACK decodes its integers with it.
-* Refusing a QPACK index that names nothing, at both ends of both tables.
-* Putting a ceiling on the frame payload we will hold.
-* Sizing the Huffman scratch buffer to what it has to hold.
-* Reading a unidirectional stream type as the variable-length integer it is.
-* Not handing the application a request we have already rejected.
-* Checking a message against the content-length it declared.
-* Reading the whole SETTINGS frame, noticing a repeated identifier, and
-  refusing one that stops mid-parameter.
-* Giving the application the peer's address, not our own.
-* This is a patch release, but `Network.HTTP3.Internal` and
-  `Network.QPACK.Internal` changed: `parseH3Frame` takes the payload limit,
-  `IFrame` has `ITooLong`, and `DecodeError` has `IllegalDynamicIndex`.
+* Security fixes.  Require http2 5.4.5.
+* Refuse a QPACK index that names nothing.
+  [#12](https://github.com/kazu-yamamoto/http3/pull/12)
+  [#20](https://github.com/kazu-yamamoto/http3/pull/20)
+* Limit the frame payload we hold.
+  [#13](https://github.com/kazu-yamamoto/http3/pull/13)
+* Size the Huffman scratch buffer to what it has to hold.
+  [#14](https://github.com/kazu-yamamoto/http3/pull/14)
+* Read a unidirectional stream type as a variable-length integer.
+  [#16](https://github.com/kazu-yamamoto/http3/pull/16)
+* Do not hand the application a request already rejected.
+  [#17](https://github.com/kazu-yamamoto/http3/pull/17)
+* Check a message against its content-length.
+  [#18](https://github.com/kazu-yamamoto/http3/pull/18)
+* Read the whole SETTINGS frame and refuse a repeated or truncated one.
+  [#19](https://github.com/kazu-yamamoto/http3/pull/19)
+  [#20](https://github.com/kazu-yamamoto/http3/pull/20)
+* Give the application the peer's address, not our own.
+  [#10](https://github.com/kazu-yamamoto/http3/pull/10)
+* `Network.HTTP3.Internal` and `Network.QPACK.Internal` changed.
 
 ## 0.1.4
 
diff --git a/Network/HTTP3/Client.hs b/Network/HTTP3/Client.hs
--- a/Network/HTTP3/Client.hs
+++ b/Network/HTTP3/Client.hs
@@ -34,7 +34,9 @@
 
 import Control.Concurrent
 import qualified Control.Exception as E
+import qualified Data.ByteString.Char8 as C8
 import qualified Data.ByteString.UTF8 as UTF8
+import Data.IORef
 import Data.IP (IPv6)
 import Network.HTTP.Semantics.Client
 import Network.HTTP.Semantics.Client.Internal
@@ -97,6 +99,48 @@
       where
         sid = QUIC.streamId strm
 
+-- | The header section of the final response, past any informational (1xx)
+-- ones before it.
+--
+-- RFC 9114, section 4.1: a response may begin with any number of
+-- informational responses, each a header section of its own, before the
+-- final one.  The first header section was taken for the response: after a
+-- 103 (Early Hints) or a 100 (Continue), the client saw that, and the body
+-- reader took the final response's HEADERS for trailers and ended the body
+-- there, the final response lost.  They are skipped now, as the application
+-- has no way here to be handed them.
+--
+-- RFC 9114 sets no limit on how many there may be, and a server could go on
+-- sending them for as long as it liked, the request never answered.  More
+-- than 'maxInformational' reset the stream with H3_EXCESSIVE_LOAD: a server
+-- has no use for more than a couple -- a 100 and a 103, say.
+recvFinalHeader
+    :: Context -> QUIC.Stream -> Source -> IO (Maybe TokenHeaderTable)
+recvFinalHeader ctx strm src = loop (0 :: Int)
+  where
+    sid = QUIC.streamId strm
+    loop n = do
+        mvt <- recvHeader ctx sid src
+        case mvt of
+            Just (_, vt)
+                | Just status <- getFieldValue tokenStatus vt
+                , informational status ->
+                    if n < maxInformational
+                        then loop (n + 1)
+                        else do
+                            QUIC.resetStream strm H3ExcessiveLoad
+                            E.throwIO $
+                                QUIC.ApplicationProtocolErrorIsSent
+                                    H3ExcessiveLoad
+                                    "too many informational responses"
+            _ -> return mvt
+    informational status = C8.length status == 3 && C8.head status == '1'
+
+-- | How many informational responses 'recvFinalHeader' takes before the
+--   final one.
+maxInformational :: Int
+maxInformational = 10
+
 sendRequest
     :: Context -> Scheme -> Authority -> Request -> (Response -> IO a) -> IO a
 sendRequest ctx scm auth (Request outobj) processResponse = do
@@ -104,12 +148,14 @@
     -- header that exceeds the indicated size".  Here, so that the caller is
     -- the one told.
     checkHeaderSize ctx hdr'
-    E.bracket (newStream ctx) closeStream $ \strm -> do
+    sent <- newIORef False
+    E.bracket (newStream ctx) (done sent) $ \strm -> do
         forkManagedTimeout ctx "H3 client: sendRequest" $ \th ->
             ( do
                 sendHeader ctx strm th hdr'
                 sendBody ctx strm th outobj
                 QUIC.shutdownStream strm
+                writeIORef sent True
             )
                 -- What goes wrong here is thrown away with the thread, and
                 -- the request would be left half sent, with the response
@@ -129,7 +175,7 @@
         src <- newSource strm
         let sid = QUIC.streamId strm
         (`E.finally` cancelUnlessReadToEnd ctx sid src) $ do
-            mvt <- recvHeader ctx sid src
+            mvt <- recvFinalHeader ctx strm src
             case mvt of
                 Nothing -> do
                     QUIC.resetStream strm H3MessageError
@@ -142,10 +188,35 @@
                         QUIC.resetStream strm H3MessageError
                         E.throwIO $ ConnectionSpecificField name
                 Just vt@(_, valtbl) -> do
-                    (readB, refH) <- newBodyReader ctx sid src valtbl
+                    (readB, refH) <-
+                        newBodyReader ctx sid src valtbl $ noContent valtbl
                     let rsp = Response $ InpObj vt Nothing readB refH
                     processResponse rsp
   where
+    -- A response defined to have no content: to HEAD, a 204 or a 304, or a
+    -- 2xx to CONNECT (RFC 9110, section 6.4.1).  Its content-length, which it
+    -- may carry, describes content it does not have; it was checked against
+    -- the empty body all the same, and the client refused such a response
+    -- with ContentLengthMismatch.
+    noContent valtbl =
+        method == Just "HEAD"
+            || status `elem` [Just "204", Just "304"]
+            || (method == Just "CONNECT" && maybe False ("2" `C8.isPrefixOf`) status)
+      where
+        status = getFieldValue tokenStatus valtbl
+    method = snd <$> find ((== ":method") . fst) hdr
+    -- Done with the stream: the response has been processed, or waiting for
+    -- it was given up -- 'timeout', say.  A request sent in full has had its
+    -- FIN, and closing the stream only tidies up.  One that has not is cut
+    -- short, and is reset (H3_REQUEST_CANCELLED).  'closeStream' used to be
+    -- called either way, and it ends the sending part with a FIN: the server
+    -- took a request body cut short for a whole one.  The sender, if it is
+    -- still at it, finds the stream closed and stops.
+    done sent strm = do
+        complete <- readIORef sent
+        if complete
+            then closeStream strm
+            else QUIC.resetStream strm H3RequestCancelled
     hdr = outObjHeaders outobj
     isIPv6 = isJust (readMaybe auth :: Maybe IPv6)
     auth'
diff --git a/Network/HTTP3/Error.hs b/Network/HTTP3/Error.hs
--- a/Network/HTTP3/Error.hs
+++ b/Network/HTTP3/Error.hs
@@ -3,6 +3,7 @@
 module Network.HTTP3.Error (
     ContentLengthMismatch (..),
     ConnectionSpecificField (..),
+    StreamResetIsReceived (..),
     ApplicationProtocolError (
         H3NoError,
         H3GeneralProtocolError,
@@ -50,6 +51,16 @@
     deriving (Eq, Show)
 
 instance E.Exception ConnectionSpecificField
+
+-- | The peer reset the stream, with this error code, before the body came to
+--   its end: what has been read of the body is not the whole of it.
+--
+-- quic tells a reset to the reader as the end of the stream, and the body
+-- reader used to take it for the end of the body.
+newtype StreamResetIsReceived = StreamResetIsReceived ApplicationProtocolError
+    deriving (Eq, Show)
+
+instance E.Exception StreamResetIsReceived
 
 {- FOURMOLU_DISABLE -}
 pattern H3NoError                :: ApplicationProtocolError
diff --git a/Network/HTTP3/Recv.hs b/Network/HTTP3/Recv.hs
--- a/Network/HTTP3/Recv.hs
+++ b/Network/HTTP3/Recv.hs
@@ -128,17 +128,26 @@
 -- Only what is actually read is counted, so a body the application never asks
 -- for is never checked. Answering that would mean draining it on the
 -- application's behalf, which is a different design from the one here.
+--
+-- A message defined to have no content -- a response to HEAD, a 204 or a
+-- 304, say -- can carry a content-length that describes content it does not
+-- have (RFC 9114, section 4.1.2).  For one of those the content is taken to
+-- be empty, whatever its content-length says: DATA on it is malformed.
 newBodyReader
     :: Context
     -> StreamId
     -> Source
     -> ValueTable
+    -> Bool
+    -- ^ Whether the message is defined to have no content
     -> IO (IO (ByteString, Bool), IORef (Maybe TokenHeaderTable))
-newBodyReader ctx sid src vt = do
+newBodyReader ctx sid src vt noContent = do
     refI <- newIORef IInit
     refH <- newIORef Nothing
     refL <- newIORef 0
-    let mcl = fst <$> (getFieldValue tokenContentLength vt >>= C8.readInt)
+    let mcl
+            | noContent = Just 0
+            | otherwise = fst <$> (getFieldValue tokenContentLength vt >>= C8.readInt)
     return (recvBody ctx sid src refI refH mcl refL, refH)
 
 recvBody
@@ -167,10 +176,26 @@
         bs <- readSource src
         if bs == ""
             then do
-                -- Not in the middle of a frame, which would mean it was cut
-                -- off.
-                when (st == IInit) $ writeIORef (sourceReadToEnd src) True
-                endOfBody
+                -- The end of the stream, but not always of the body.  quic
+                -- tells a reset of the peer's as an end too, and a stream
+                -- that ends in the middle of a frame was cut off: RFC 9114,
+                -- section 7.1, makes that a connection error.  Both used to
+                -- be taken for the end of the body, so that a body cut
+                -- short was delivered as a whole one if it had no
+                -- content-length to check.
+                mreset <- resetReceived $ sourceStream src
+                case mreset of
+                    Just aerr -> E.throwIO $ StreamResetIsReceived aerr
+                    Nothing
+                        | st /= IInit -> do
+                            abort ctx H3FrameError
+                            E.throwIO $
+                                ApplicationProtocolErrorIsSent
+                                    H3FrameError
+                                    "a frame cut off by the end of the stream"
+                        | otherwise -> do
+                            writeIORef (sourceReadToEnd src) True
+                            endOfBody
             else case parseH3Frame lim st bs of
                 ITooLong _ _ -> do
                     abort ctx H3ExcessiveLoad
diff --git a/Network/HTTP3/Server.hs b/Network/HTTP3/Server.hs
--- a/Network/HTTP3/Server.hs
+++ b/Network/HTTP3/Server.hs
@@ -124,14 +124,35 @@
   where
     sid = QUIC.streamId strm
     reset se
-        | isAsyncException se = E.throwIO se
+        -- Killed: by the timeout, or with the connection.  Whatever was
+        -- sent of the response is not the whole of it, and is reset here.
+        -- It used to be passed on as it was, and the finalizer
+        -- ('closeStream') ended the stream with a FIN: the client took a
+        -- response cut short for a whole one.  A response that has gone out
+        -- in full has had its FIN already, and 'resetStream' does nothing.
+        | isAsyncException se = do
+            QUIC.resetStream strm H3InternalError
+            E.throwIO se
         | Just (_ :: DecodeError) <- E.fromException se =
             abort ctx QpackDecompressionFailed
         -- The response was more than the client will take, and the
         -- application did nothing about it.  Our failure, not the client's.
         | Just (_ :: FieldSectionTooLargeForPeer) <- E.fromException se =
             QUIC.resetStream strm H3InternalError
-        | otherwise = QUIC.resetStream strm H3MessageError
+        -- The request is malformed (RFC 9114, section 4.1.2): the body
+        -- reader says so, and the handler passes it on.
+        | malformed se = QUIC.resetStream strm H3MessageError
+        -- The client reset the stream, and the handler gave up on it.
+        | Just (_ :: StreamResetIsReceived) <- E.fromException se =
+            QUIC.resetStream strm H3RequestCancelled
+        -- Anything else is the handler's own failure.  H3_MESSAGE_ERROR
+        -- was sent for it, which says the request was malformed.
+        | otherwise = QUIC.resetStream strm H3InternalError
+    malformed se
+        | Just (_ :: ContentLengthMismatch) <- E.fromException se = True
+        | Just (_ :: ConnectionSpecificField) <- E.fromException se = True
+        | Just FieldSectionTooLarge <- E.fromException se = True
+        | otherwise = False
 
 processRequestIO :: Context -> ((Stream, Request) -> IO ()) -> Stream -> IO ()
 processRequestIO ctx put strm = E.handle reset $ do
@@ -198,7 +219,7 @@
   where
     build = do
         let sid = QUIC.streamId strm
-        (readB, refH) <- newBodyReader ctx sid src vt
+        (readB, refH) <- newBodyReader ctx sid src vt False
         return $ Request $ InpObj ht Nothing readB refH
 
 sendResponse
diff --git a/Network/QPACK.hs b/Network/QPACK.hs
--- a/Network/QPACK.hs
+++ b/Network/QPACK.hs
@@ -188,8 +188,19 @@
                 }
     return (enc, handler, ctl)
 
+-- | The most a field can take in either buffer, as a field line or as an
+-- insertion on the encoder stream.
+--
+-- Huffman coding is tried in place before it is known to be the shorter, and
+-- a code is at most 30 bits: under four octets per octet.  To that come the
+-- first octet and the integers -- an index and two lengths, a few octets
+-- each.  The raw length plus 8 was taken for this, and a field of octets
+-- above 0x7f, three to four times longer in Huffman, overran the shared
+-- buffers part-way through a chunk: after an earlier field had gone into the
+-- dynamic table, and before its insertion was sent, so that the table was
+-- out of step with the peer's from there on.
 tokenHeaderSize :: TokenHeader -> Int
-tokenHeaderSize (t, v) = BS.length (CI.original (tokenKey t)) + BS.length v + 8 -- adhoc overhead
+tokenHeaderSize (t, v) = 4 * (BS.length (CI.original (tokenKey t)) + BS.length v) + 32
 
 -- | Taking as many fields as fit in @lim@, but always at least one.
 --
@@ -220,9 +231,8 @@
 
 -- | Encoding a chunk from 'splitThrough', in the shared buffers when it fits.
 --
--- One that does not is a single large field, and gets buffers of its own.
--- Their size allows for Huffman coding being tried in place before it is known
--- to be the shorter: a code is at most 30 bits, so under four octets per octet.
+-- One that does not is a single large field, and gets buffers of its own, of
+-- the size 'tokenHeaderSize' allows for it.
 encodeChunk
     :: Buffer
     -> BufferSize
@@ -236,7 +246,7 @@
     | siz <= min bufsiz1 bufsiz2 =
         qpackEncodeHeader buf1 bufsiz1 buf2 bufsiz2 huff dyntbl ts
     | otherwise = do
-        let bufsiz = siz * 4 + 64
+        let bufsiz = siz
         gcbuf1 <- mallocPlainForeignPtrBytes bufsiz
         gcbuf2 <- mallocPlainForeignPtrBytes bufsiz
         withForeignPtr gcbuf1 $ \b1 ->
@@ -532,9 +542,26 @@
         let bs
                 | bs0 == "" = bs1
                 | otherwise = bs0 <> bs1
-        when (bs1 /= "") $ do
-            leftover <- encoderInstructionHandlerS decCapLim dyntbl bs
-            loop leftover
+        when (bs1 /= "") $ case encoderInstructionLength bs of
+            Just len
+                | len > maxInstructionLength -> E.throwIO EncoderInstructionError
+                -- Not all there yet: wait for the rest before decoding.
+                | len > BS.length bs -> loop bs
+            _ -> do
+                leftover <- encoderInstructionHandlerS decCapLim dyntbl bs
+                loop leftover
+    -- An instruction inserts an entry, which may not be larger than the
+    -- capacity, and the capacity not larger than we said (RFC 9204, sections
+    -- 4.3.1 to 4.3.3).  Its strings come to no more than four octets each
+    -- coded in Huffman, a code being at most 30 bits; 32 more allows for the
+    -- integers.  A longer one cannot be valid.
+    --
+    -- An instruction that had not all arrived used to be decoded from its
+    -- beginning again with each piece of it that did, and kept for as long as
+    -- the peer went on: one of a megabyte, never finished, cost seconds of CPU
+    -- and gigabytes of allocation.  Now it is decoded once it is all there,
+    -- and refused as soon as its length says it cannot be valid.
+    maxInstructionLength = 4 * decCapLim + 32
 
 -- Note: dyntbl for decoder
 encoderInstructionHandlerS :: Int -> DynamicTable -> EncoderInstructionHandlerS
diff --git a/Network/QPACK/Instruction.hs b/Network/QPACK/Instruction.hs
--- a/Network/QPACK/Instruction.hs
+++ b/Network/QPACK/Instruction.hs
@@ -9,6 +9,7 @@
     encodeEncoderInstructions,
     decodeEncoderInstructions,
     decodeEncoderInstructions',
+    encoderInstructionLength,
     encodeEI,
     decodeEI,
 
@@ -21,6 +22,7 @@
 ) where
 
 import qualified Control.Exception as E
+import qualified Data.ByteString as BS
 import qualified Data.ByteString.Char8 as BS8
 import Data.CaseInsensitive
 import Network.ByteOrder
@@ -123,6 +125,50 @@
                         left <- extractByteString rbuf rn
                         let eis = build []
                         return (eis, left)
+
+-- | The length in octets of the first encoder instruction, once enough of it
+-- has arrived to tell: its first octet and the integers that give the lengths
+-- of its strings (RFC 9204, section 4.3).  'Nothing' until then.
+--
+-- So that an instruction arriving a piece at a time is decoded once it is
+-- all there, rather than tried again from its beginning with each piece.
+encoderInstructionLength :: ByteString -> Maybe Int
+encoderInstructionLength bs = case BS.uncons bs of
+    Nothing -> Nothing
+    Just (w0, _)
+        -- Insert with name reference: an index, then the value
+        | w0 `testBit` 7 -> int 6 0 >>= str . snd
+        -- Insert with literal name: the name, then the value
+        | w0 `testBit` 6 -> do
+            (len, off) <- int 5 0
+            str (off + len)
+        -- Set dynamic table capacity, or duplicate: an integer
+        | otherwise -> snd <$> int 5 0
+  where
+    -- A string at the offset: its length, with a 7-bit prefix after the
+    -- Huffman bit, then the string itself.
+    str off = do
+        (len, off') <- int 7 off
+        return $ off' + len
+    -- An integer with an n-bit prefix at the offset: its value, and the
+    -- offset after it.  One too long to be a length here is taken as one
+    -- longer than any instruction can be.
+    int :: Int -> Int -> Maybe (Int, Int)
+    int n off = do
+        w <- at off
+        let mask = (1 `shiftL` n) - 1
+            v = fromIntegral w .&. mask
+        if v < mask then Just (v, off + 1) else go (off + 1) v 0
+      where
+        go i acc m
+            | m > 28 = Just (maxBound `div` 2, i)
+            | otherwise = do
+                b <- at i
+                let acc' = acc + ((fromIntegral b .&. 0x7f) `shiftL` m)
+                if b < 0x80 then Just (acc', i + 1) else go (i + 1) acc' (m + 7)
+    at i
+        | i < BS.length bs = Just $ BS.index bs i
+        | otherwise = Nothing
 
 decodeEI :: HuffmanDecoder -> ReadBuffer -> IO EncoderInstruction
 decodeEI hufdec rbuf = do
diff --git a/Network/QPACK/Table/Dynamic.hs b/Network/QPACK/Table/Dynamic.hs
--- a/Network/QPACK/Table/Dynamic.hs
+++ b/Network/QPACK/Table/Dynamic.hs
@@ -151,6 +151,7 @@
     | DecodeInfo
         { huffmanDecoder  :: HuffmanDecoder  -- only for encoder instruction handler
         , blockedStreamsD :: IORef Int
+        , evictedD        :: TVar AbsoluteIndex -- the oldest entry not evicted
         }
 {- FOURMOLU_ENABLE -}
 
@@ -166,7 +167,7 @@
     , debugQPACK        :: IORef Bool
     , capaReady         :: IORef Bool
     , tableSize         :: TVar Size
-    , maxTableSize      :: IORef Size
+    , maxTableSize      :: TVar Size
     , sendIns           :: ByteString -> IO ()
     , maxHeaderSize     :: IORef Int
     , maxBlockedStreams :: IORef Int
@@ -207,6 +208,7 @@
     gcbuf <- mallocPlainForeignPtrBytes huftmpsiz
     let huffmanDecoder = decodeH gcbuf huftmpsiz
     blockedStreamsD <- newIORef 0
+    evictedD <- newTVarIO 0
     newDynamicTable DecodeInfo{..} sendDI
 
 newDynamicTable :: CodeInfo -> (ByteString -> IO ()) -> IO DynamicTable
@@ -221,7 +223,7 @@
     debugQPACK <- newIORef False
     capaReady <- newIORef False
     tableSize <- newTVarIO 0
-    maxTableSize <- newIORef 0
+    maxTableSize <- newTVarIO 0
     let sendIns = send
     maxHeaderSize <- newIORef maxBound
     maxBlockedStreams <- newIORef 0
@@ -238,13 +240,13 @@
 -- | The capacity, as last set.  'getTableCapacity' is how much of it the
 --   entries take, whatever its name says.
 getMaxTableCapacity :: DynamicTable -> IO Int
-getMaxTableCapacity DynamicTable{..} = readIORef maxTableSize
+getMaxTableCapacity DynamicTable{..} = readTVarIO maxTableSize
 
 setTableCapacity :: DynamicTable -> Int -> IO ()
 setTableCapacity dyntbl@DynamicTable{..} maxsiz = do
     qpackDebug dyntbl $ putStrLn $ "setTableCapacity " ++ show maxsiz
     when (maxN >= 1) $ do
-        writeIORef maxTableSize maxsiz
+        atomically $ writeTVar maxTableSize maxsiz
         tbl <- atomically $ newArray (0, end) dummyEntry
         atomically $ do
             writeTVar maxNumOfEntries maxN
@@ -279,7 +281,10 @@
         atomically $ do
             writeTVar maxNumOfEntries maxN
             writeTVar circularTable tbl
-    writeIORef maxTableSize cap
+    atomically $ writeTVar maxTableSize cap
+    -- Lowering the capacity evicts what no longer fits (RFC 9204, section
+    -- 3.2.2).
+    atomically $ evictDecoder cap dyntbl
     -- No entry is smaller than 32, so below that nothing can be inserted.
     writeIORef capaReady (maxN >= 1 && maxNumbers cap >= 1)
 
@@ -321,17 +326,57 @@
     resetReference dyntbl ai
     return ai
 
+-- | Inserting an entry into the decoder's table, within the capacity the
+--   encoder set.
+--
+-- RFC 9204, section 3.2.2: before an entry is added, the oldest entries are
+-- evicted until it fits, and an entry larger than the capacity is an error
+-- (section 4.3.2, QPACK_ENCODER_STREAM_ERROR).  Neither was done: an entry
+-- of any size went into the next slot of the ring, which has a slot for each
+-- 32 octets of the capacity we announced, so that a peer could have us keep
+-- that many entries of a megabyte each -- 128 MB for a capacity of 4096 --
+-- and refer to entries a conforming decoder would have evicted.
+--
+-- No entry is smaller than 32 octets and the capacity is no more than we
+-- announced, so the entries left after eviction never fill the ring: the
+-- slot an entry goes into holds nothing still in use.
+--
+-- The capacity is read here, in the same transaction, rather than passed:
+-- this is exported from "Network.QPACK.Internal", and its type stays as it
+-- was.
 insertEntryToDecoder :: Entry -> DynamicTable -> STM AbsoluteIndex
-insertEntryToDecoder ent DynamicTable{..} = do
+insertEntryToDecoder ent dyntbl@DynamicTable{..} = do
+    cap <- readTVar maxTableSize
+    let siz = entrySize ent
+    when (siz > cap) $ throwSTM EncoderInstructionError
+    evictDecoder (cap - siz) dyntbl
     x@(InsertionPoint insp) <- readTVar insertionPoint
     writeTVar insertionPoint (x + 1)
     maxN <- readTVar maxNumOfEntries
     let i = insp `mod` maxN
     table <- readTVar circularTable
     unsafeWrite table i ent
-    modifyTVar' tableSize (+ entrySize ent)
+    modifyTVar' tableSize (+ siz)
     return $ AbsoluteIndex insp
 
+-- | Evicting the oldest entries of the decoder's table until the entries
+--   left take no more than the given size.
+evictDecoder :: Size -> DynamicTable -> STM ()
+evictDecoder target DynamicTable{..} = case codeInfo of
+    DecodeInfo{..} -> loop evictedD
+    _ -> return ()
+  where
+    loop evicted = do
+        siz <- readTVar tableSize
+        when (siz > target) $ do
+            AbsoluteIndex ai <- readTVar evicted
+            maxN <- readTVar maxNumOfEntries
+            table <- readTVar circularTable
+            ent <- unsafeRead table (ai `mod` maxN)
+            writeTVar tableSize (siz - entrySize ent)
+            writeTVar evicted $ AbsoluteIndex (ai + 1)
+            loop evicted
+
 -- | Look up an entry of the dynamic table by absolute index.
 --
 -- The live window is the most recent 'maxNumOfEntries' insertions. An index
@@ -352,7 +397,11 @@
     -- @ip - maxN@ is negative, which would let a negative index through; the
     -- index conversions produce those readily, since a pre-base index larger
     -- than the base gives one.
-    let lo = max 0 (ip - maxN)
+    -- An entry the decoder has evicted is gone too.
+    evicted <- case codeInfo of
+        DecodeInfo{..} -> (\(AbsoluteIndex ai) -> ai) <$> readTVar evictedD
+        _ -> return 0
+    let lo = maximum [0, ip - maxN, evicted]
     when (maxN == 0 || idx < lo || idx >= ip) $
         throwSTM $
             IllegalDynamicIndex idx
@@ -673,7 +722,7 @@
 canInsertEntry DynamicTable{..} ent mai = do
     let siz = entrySize ent
     tblsiz <- readTVarIO tableSize
-    maxtblsiz <- readIORef maxTableSize
+    maxtblsiz <- readTVarIO maxTableSize
     if tblsiz + siz <= maxtblsiz
         then
             return True
@@ -711,7 +760,7 @@
   where
     loop = do
         tblsize <- readTVarIO tableSize
-        maxtblsize <- readIORef maxTableSize
+        maxtblsize <- readTVarIO maxTableSize
         unless (tblsize <= maxtblsize) $ do
             dropped <- tryDrop dyntbl
             if dropped then loop else error "dropIfNecessary"
@@ -845,7 +894,7 @@
             size <- calcSize end 0
             size0 <- readTVarIO tableSize
             when (size /= size0) $ error $ "checkAbsoluteIndex: size /= size0) " ++ tag
-            lim <- readIORef maxTableSize
+            lim <- readTVarIO maxTableSize
             when (size > lim) $ error $ "checkAbsoluteIndex: size > lim " ++ tag
             putStrLn $ "    check: tblsiz: " ++ show size ++ " " ++ show ai ++ " " ++ tag
         else
diff --git a/http3.cabal b/http3.cabal
--- a/http3.cabal
+++ b/http3.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.4
 name:               http3
-version:            0.1.7
+version:            0.1.8
 license:            BSD-3-Clause
 license-file:       LICENSE
 maintainer:         Kazu Yamamoto <kazu@iij.ad.jp>
@@ -92,7 +92,7 @@
         quic >= 0.3.10 && < 0.4,
         sockaddr,
         stm,
-        time-manager >= 0.2.3 && <0.4,
+        time-manager >= 0.2.3 && <0.5,
         utf8-string >=1.0 && <1.1
 
 executable h3-server
@@ -208,6 +208,8 @@
         HTTP3.Error
         HTTP3.ErrorSpec
         HTTP3.FrameSpec
+        HTTP3.HandlerSpec
+        HTTP3.InformationalSpec
         HTTP3.LimitSpec
         HTTP3.Server
         HTTP3.ServerSpec
@@ -242,4 +244,5 @@
         network,
         quic,
         stm,
+        time-manager,
         tls
diff --git a/test/HTTP3/Error.hs b/test/HTTP3/Error.hs
--- a/test/HTTP3/Error.hs
+++ b/test/HTTP3/Error.hs
@@ -3,6 +3,7 @@
 
 module HTTP3.Error (
     h3ErrorSpec,
+    h3DrainSpec,
 ) where
 
 import Control.Concurrent
@@ -121,16 +122,6 @@
                     qcc' = addQUICHook qcc $ setOnResetStreamReceived $ \_strm aerr -> E.throwIO (ApplicationProtocolErrorIsReceived aerr "")
                 runC qcc' cconf conf ms
                     `shouldThrow` applicationProtocolErrorsIn [H3MessageError]
-        it
-            "MUST treat content that does not match content-length as malformed [HTTP/3 4.1.2]"
-            $ \_ -> do
-                -- content-length says five octets and the request carries
-                -- none.  /drain reads the body, which is where the count is
-                -- checked; a body nobody reads is never counted.
-                let req = H3.requestNoBody methodPost "/drain" [("content-length", "5")]
-                    qcc' = addQUICHook qcc $ setOnResetStreamReceived $ \_strm aerr -> E.throwIO (ApplicationProtocolErrorIsReceived aerr "")
-                runCReq req qcc' cconf conf0 ms
-                    `shouldThrow` applicationProtocolErrorsIn [H3MessageError]
         forM_ connectionSpecific $ \field@(name, _) ->
             it
                 ( "MUST treat a request with "
@@ -143,15 +134,6 @@
                     refusedNotServed (served x) $
                         runCReq req qcc' cconf conf0 ms
                             `shouldThrow` applicationProtocolErrorsIn [H3MessageError]
-        it
-            "MUST treat a request whose trailers carry connection as malformed [HTTP/3 4.2]"
-            $ \_ -> do
-                -- /drain reads the body, and so the trailers after it.
-                let req0 = H3.requestBuilder methodPost "/drain" [] "hello"
-                    req = H3.setRequestTrailersMaker req0 connectionTrailer
-                    qcc' = addQUICHook qcc $ setOnResetStreamReceived $ \_strm aerr -> E.throwIO (ApplicationProtocolErrorIsReceived aerr "")
-                runCReq req qcc' cconf conf0 ms
-                    `shouldThrow` applicationProtocolErrorsIn [H3MessageError]
         it "MUST accept TE with trailers [HTTP/3 4.2]" $ \_ -> do
             let req = H3.requestNoBody methodGet "/" [("te", "trailers")]
             runCReq req qcc cconf conf0 ms `shouldReturn` Just ()
@@ -334,6 +316,42 @@
         { H3.confQDecoderConfig =
             (H3.confQDecoderConfig conf){dcMaxTableCapacity = 0}
         }
+
+-- | The error cases that need the server to read a request's body.
+--
+-- A server checks content-length, and the fields of trailers, only as its
+-- application reads the body; an application that answers without reading
+-- it never gets to either, and nothing tells from outside which paths read
+-- one.  These go to /drain, where our test server reads the body, and so
+-- are not part of 'h3ErrorSpec': run against a server elsewhere, as h3spec
+-- does, they would fail whatever that server does.
+h3DrainSpec
+    :: ClientConfig
+    -> H3.ClientConfig
+    -> Millisecond
+    -> SpecWith a
+h3DrainSpec qcc cconf ms = do
+    conf0 <- runIO H3.allocSimpleConfig
+    describe "HTTP/3 servers reading a request's body" $ do
+        it
+            "MUST treat content that does not match content-length as malformed [HTTP/3 4.1.2]"
+            $ \_ -> do
+                -- content-length says five octets and the request carries
+                -- none.  /drain reads the body, which is where the count is
+                -- checked; a body nobody reads is never counted.
+                let req = H3.requestNoBody methodPost "/drain" [("content-length", "5")]
+                    qcc' = addQUICHook qcc $ setOnResetStreamReceived $ \_strm aerr -> E.throwIO (ApplicationProtocolErrorIsReceived aerr "")
+                runCReq req qcc' cconf conf0 ms
+                    `shouldThrow` applicationProtocolErrorsIn [H3MessageError]
+        it
+            "MUST treat a request whose trailers carry connection as malformed [HTTP/3 4.2]"
+            $ \_ -> do
+                -- /drain reads the body, and so the trailers after it.
+                let req0 = H3.requestBuilder methodPost "/drain" [] "hello"
+                    req = H3.setRequestTrailersMaker req0 connectionTrailer
+                    qcc' = addQUICHook qcc $ setOnResetStreamReceived $ \_strm aerr -> E.throwIO (ApplicationProtocolErrorIsReceived aerr "")
+                runCReq req qcc' cconf conf0 ms
+                    `shouldThrow` applicationProtocolErrorsIn [H3MessageError]
 
 addHook :: H3.Config -> (H3.Hooks -> H3.Hooks) -> H3.Config
 addHook conf modify = conf'
diff --git a/test/HTTP3/ErrorSpec.hs b/test/HTTP3/ErrorSpec.hs
--- a/test/HTTP3/ErrorSpec.hs
+++ b/test/HTTP3/ErrorSpec.hs
@@ -14,7 +14,9 @@
 spec =
     beforeAll start $
         afterAll (teardown . fst) $
-            h3ErrorSpec testClientConfig testH3ClientConfig 2000 served -- 2 seconds
+            -- 2 seconds
+            h3ErrorSpec testClientConfig testH3ClientConfig 2000 served
+                >> h3DrainSpec testClientConfig testH3ClientConfig 2000
   where
     served (_, ref) = Just $ readIORef ref
     start = do
diff --git a/test/HTTP3/HandlerSpec.hs b/test/HTTP3/HandlerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HTTP3/HandlerSpec.hs
@@ -0,0 +1,37 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module HTTP3.HandlerSpec where
+
+import qualified Control.Exception as E
+import qualified Data.ByteString as B
+import Network.HTTP.Types
+import qualified Network.HTTP3.Client as C
+import Network.HTTP3.Internal (
+    ApplicationProtocolError (..),
+    StreamResetIsReceived (..),
+ )
+import Network.HTTP3.Server
+import qualified Network.QUIC.Client as QUIC
+import System.Timeout (timeout)
+import Test.Hspec
+
+import HTTP3.Config
+import HTTP3.Server
+
+-- | A server whose handlers time out after a second of not sending.
+spec :: Spec
+spec = beforeAll (setupShortTimeout server) $ afterAll teardown $ do
+    describe "H3 server with a short timeout" $ do
+        it "resets a response its handler is killed part-way through" $ \_ -> do
+            -- The handler sends some of the body and then nothing: the
+            -- timeout kills it.  The stream used to be ended with a FIN, and
+            -- the client took what it had for the whole body.
+            r <- timeout 5000000 $ QUIC.run testClientConfig $ \conn ->
+                E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+                    C.run conn testH3ClientConfig conf $ \sendRequest _aux ->
+                        E.try $ sendRequest (C.requestNoBody methodGet "/stall" []) $ \rsp -> do
+                            let drain acc = do
+                                    bs <- C.getResponseBodyChunk rsp
+                                    if B.null bs then return acc else drain (acc <> bs)
+                            drain ""
+            r `shouldBe` Just (Left (StreamResetIsReceived H3InternalError))
diff --git a/test/HTTP3/InformationalSpec.hs b/test/HTTP3/InformationalSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HTTP3/InformationalSpec.hs
@@ -0,0 +1,70 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module HTTP3.InformationalSpec where
+
+import qualified Control.Exception as E
+import qualified Data.ByteString as B
+import Network.HTTP.Types
+import qualified Network.HTTP3.Client as C
+import Network.HTTP3.Internal (
+    ApplicationProtocolError (..),
+    H3Frame (..),
+    H3FrameType (..),
+ )
+import Network.HTTP3.Server
+import qualified Network.QUIC as Q
+import qualified Network.QUIC.Client as QUIC
+import System.Timeout (timeout)
+import Test.Hspec
+
+import HTTP3.Config
+import HTTP3.Server
+
+spec :: Spec
+spec = do
+    -- Two informational responses, 100 (Continue) and 103 (Early Hints),
+    -- each a HEADERS frame of its own, before every response.
+    describe "H3 client with informational responses" $
+        beforeAll (setupWith (early [continue, earlyHints]) server 4096) $
+            afterAll teardown $
+                it "takes the final response past informational ones" $ \_ -> do
+                    -- The first header section was taken for the response,
+                    -- and the body reader took the next one for trailers: the
+                    -- client saw a 100 with no body, and the final response
+                    -- was lost.
+                    r <- get
+                    case r of
+                        Just (Right x) -> x `shouldBe` (Just ok200, "Hello, world!\n")
+                        _ -> expectationFailure $ "unexpected: " ++ show r
+    -- Twenty of them before every response.
+    describe "H3 client with too many informational responses" $
+        beforeAll (setupWith (early (replicate 20 earlyHints)) server 4096) $
+            afterAll teardown $
+                it "resets a response with too many informational ones" $ \_ -> do
+                    -- RFC 9114 sets no limit, and a server could go on sending
+                    -- them for ever.
+                    r <- get
+                    case r of
+                        Just (Left (Q.ApplicationProtocolErrorIsSent H3ExcessiveLoad _)) ->
+                            return ()
+                        _ -> expectationFailure $ "unexpected: " ++ show r
+  where
+    get :: IO (Maybe (Either Q.QUICException (Maybe Status, B.ByteString)))
+    get = timeout 3000000 $ E.try $ QUIC.run testClientConfig $ \conn ->
+        E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+            C.run conn testH3ClientConfig conf $ \sendRequest _aux ->
+                sendRequest (C.requestNoBody methodGet "/" []) $ \rsp -> do
+                    let drain acc = do
+                            bs <- C.getResponseBodyChunk rsp
+                            if B.null bs then return acc else drain (acc <> bs)
+                    body <- drain ""
+                    return (C.responseStatus rsp, body)
+    early frames conf =
+        conf
+            { confHooks =
+                (confHooks conf){onHeadersFrameCreated = (frames ++)}
+            }
+    -- Required Insert Count 0 and Base 0, then one field line referring to
+    -- the static table: :status 100 is index 63, :status 103 index 24.
+    continue = H3Frame H3FrameHeaders "\x00\x00\xff\x00"
+    earlyHints = H3Frame H3FrameHeaders "\x00\x00\xd8"
diff --git a/test/HTTP3/Server.hs b/test/HTTP3/Server.hs
--- a/test/HTTP3/Server.hs
+++ b/test/HTTP3/Server.hs
@@ -4,8 +4,10 @@
 module HTTP3.Server (
     setup,
     setupWith,
+    setupShortTimeout,
     server,
     countingServer,
+    recordedBody,
     teardown,
     trailersMaker,
     firstTrailerValue,
@@ -31,6 +33,8 @@
 import Network.HTTP3.Server
 import qualified Network.QUIC.Server as QUIC
 import Network.Socket ()
+import System.IO.Unsafe (unsafePerformIO)
+import qualified System.TimeManager as T
 import Test.Hspec
 
 import HTTP3.Config
@@ -55,6 +59,18 @@
 
         run conn (modify conf) svr
 
+-- | 'setup', with handlers timing out after a second of not sending.
+setupShortTimeout :: Server -> IO ThreadId
+setupShortTimeout svr = do
+    sc <- makeTestServerConfig
+    tid <- forkIO $ QUIC.run sc loop
+    threadDelay 500000 -- give enough time to the server
+    return tid
+  where
+    loop conn = T.withManager 1000000 $ \mgr ->
+        E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+            run conn conf{confTimeoutManager = mgr} svr
+
 teardown :: ThreadId -> IO ()
 teardown tid = killThread tid
 
@@ -76,6 +92,15 @@
         -- frames: the body throws, and the stream is reset.
         Just "/reset" ->
             sendResponse (responseStreaming ok200 [] resetAfterOne) []
+        -- No content, and a content-length describing content it does not
+        -- have: allowed for a 304 (RFC 9114, section 4.1.2).
+        Just "/not-modified" ->
+            sendResponse (responseNoBody notModified304 bigLength) []
+        -- The same for a 200, which is meant to have content: malformed.
+        Just "/no-content" -> sendResponse (responseNoBody ok200 bigLength) []
+        -- Some of the body, then nothing for longer than the timeout of
+        -- 'setupShortTimeout': the handler is killed part-way.
+        Just "/stall" -> sendResponse (responseStreaming ok200 [] stall) []
         -- A response whose header section is some 2K.
         Just "/bigheader" ->
             sendResponse (responseNoBody ok200 [("x-big", B.replicate 2000 0x61)]) []
@@ -90,6 +115,15 @@
                     unless (B.null bs) loop
             loop
             sendResponse responseHello []
+        -- What the handler made of the body: how long it was, or what it
+        -- was told instead of its end ('recordedBody').
+        Just "/record" -> do
+            let loop n = do
+                    bs <- getRequestBodyChunk req
+                    if B.null bs then return n else loop (n + B.length bs)
+            r <- E.try $ loop (0 :: Int)
+            putMVar recordedBody $ either (\e -> Left (show (e :: E.SomeException))) Right r
+            sendResponse responseHello []
         -- Answers with the length of the body it read.
         Just "/length" -> do
             let loop n = do
@@ -98,13 +132,30 @@
             n <- loop (0 :: Int)
             sendResponse (responseLength n) []
         _ -> sendResponse responseHello []
+    -- No content, as a response to HEAD has, and the content-length of the
+    -- GET.
+    Just "HEAD" -> sendResponse (responseNoBody ok200 bigLength) []
     _ -> sendResponse response405 []
 
+-- | What the handler of /record made of the body it was sent.
+recordedBody :: MVar (Either String Int)
+recordedBody = unsafePerformIO newEmptyMVar
+{-# NOINLINE recordedBody #-}
+
+bigLength :: ResponseHeaders
+bigLength = [("content-length", "1000")]
+
 responseLength :: Int -> Response
 responseLength n = responseBuilder ok200 header body
   where
     header = [("Content-Type", "text/plain")]
     body = byteString $ C8.pack $ show n
+
+stall :: (Builder -> IO ()) -> IO () -> IO ()
+stall write flush = do
+    write $ byteString "partial"
+    flush
+    threadDelay 10000000
 
 resetAfterOne :: (Builder -> IO ()) -> IO () -> IO ()
 resetAfterOne write flush = do
diff --git a/test/HTTP3/ServerSpec.hs b/test/HTTP3/ServerSpec.hs
--- a/test/HTTP3/ServerSpec.hs
+++ b/test/HTTP3/ServerSpec.hs
@@ -1,20 +1,24 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeApplications #-}
 
 module HTTP3.ServerSpec where
 
-import Control.Concurrent (threadDelay)
+import Control.Concurrent (takeMVar, threadDelay, tryTakeMVar)
 import Control.Concurrent.Async
 import qualified Control.Exception as E
 import Control.Monad
 import qualified Data.ByteString as B
 import Data.IORef
+import Data.List (isInfixOf)
 import Network.HTTP.Types
 import qualified Network.HTTP3.Client as C
 import Network.HTTP3.Internal (
     ApplicationProtocolError (..),
+    ContentLengthMismatch (..),
     H3Frame (..),
     H3FrameType (..),
+    StreamResetIsReceived (..),
  )
 import Network.HTTP3.Server
 import Network.QPACK (
@@ -53,6 +57,14 @@
             runCancelClient
         it "cancels a stream reset between two frames" $ \_ ->
             runResetCancelClient
+        it "tells the handler of a request body cut short by a reset" $ \_ ->
+            runResetRequestClient
+        it "resets a request interrupted before its body is sent" $ \_ ->
+            runInterruptedRequestClient
+        it "accepts a content-length on a response with no content" $ \_ ->
+            runNoContentClient
+        it "closes the connection on a stream ending in the middle of a frame" $ \_ ->
+            runTruncatedFrameClient
         it "stops reading a unidirectional stream of an unknown type" $ \_ ->
             runUnknownStreamClient
         it "gives back what a stream of an unknown type took of the limit" $ \_ ->
@@ -277,15 +289,108 @@
         E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
             C.run conn testH3ClientConfig conf $ \sendRequest _aux -> do
                 let req = C.requestNoBody methodGet "/reset" []
-                -- Stream 0, read to what looks like its end.
-                sendRequest req $ \rsp -> do
+                -- Stream 0, read to its reset.  That used to look like the
+                -- end of the body; it is told as what it is now.
+                r <- E.try $ sendRequest req $ \rsp -> do
                     let drain = do
                             bs <- C.getResponseBodyChunk rsp
                             unless (B.null bs) drain
                     drain
+                case r of
+                    -- The body threw: the server's failure, not the
+                    -- client's.  H3_MESSAGE_ERROR was sent for it.
+                    Left (StreamResetIsReceived e) -> e `shouldBe` H3InternalError
+                    Right () -> expectationFailure "the reset was taken for the end"
                 threadDelay 100000
     bs <- B.concat <$> readIORef sentOnDecoderStream
     B.elem 0x40 bs `shouldBe` True
+
+-- | A request body that throws part-way, so that the client resets the
+-- stream: the handler must not take what it read for the whole body.
+runResetRequestClient :: IO ()
+runResetRequestClient = do
+    _ <- tryTakeMVar recordedBody
+    QUIC.run testClientConfig $ \conn ->
+        E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+            C.run conn testH3ClientConfig conf $ \sendRequest _aux -> do
+                let req = C.requestStreaming methodPost "/record" [] $ \write flush -> do
+                        write "partial"
+                        flush
+                        threadDelay 100000
+                        E.throwIO $ userError "body failed"
+                _ <- timeout 1000000 $ E.try @E.SomeException $ sendRequest req $ \_ -> return ()
+                r <- timeout 2000000 $ takeMVar recordedBody
+                r `shouldSatisfy` \x -> case x of
+                    Just (Left e) -> "StreamResetIsReceived" `isInfixOf` e
+                    _ -> False
+
+-- | A request interrupted by 'timeout' while its body is still being sent:
+-- the server must not take the part of it that arrived for the whole body.
+-- The stream used to be closed with a FIN; it is reset now, with
+-- H3_REQUEST_CANCELLED.
+runInterruptedRequestClient :: IO ()
+runInterruptedRequestClient = do
+    _ <- tryTakeMVar recordedBody
+    QUIC.run testClientConfig $ \conn ->
+        E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+            C.run conn testH3ClientConfig conf $ \sendRequest _aux -> do
+                let req = C.requestStreaming methodPost "/record" [] $ \write flush -> do
+                        write "partial"
+                        flush
+                        threadDelay 2000000
+                timeout 300000 (sendRequest req $ \_ -> return ())
+                    `shouldReturn` Nothing
+                r <- timeout 2000000 $ takeMVar recordedBody
+                r
+                    `shouldBe` Just
+                        (Left (show (StreamResetIsReceived H3RequestCancelled)))
+
+-- | Responses with no content and a content-length of 1000: to HEAD, and a
+-- 304.  The client checked the empty body against it and refused them with
+-- ContentLengthMismatch.  A 200 like that is malformed still.
+runNoContentClient :: IO ()
+runNoContentClient =
+    QUIC.run testClientConfig $ \conn ->
+        E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+            C.run conn testH3ClientConfig conf $ \sendRequest _aux -> do
+                let body rsp = do
+                        bs <- C.getResponseBodyChunk rsp
+                        if B.null bs then return "" else (bs <>) <$> body rsp
+                    get method path =
+                        sendRequest (C.requestNoBody method path []) $ \rsp ->
+                            (,) (C.responseStatus rsp) <$> body rsp
+                get methodHead "/" `shouldReturn` (Just ok200, "")
+                get methodGet "/not-modified" `shouldReturn` (Just notModified304, "")
+                get methodGet "/no-content" `shouldThrow` \e -> case e of
+                    ContentLengthMismatch 1000 0 -> True
+                    _ -> False
+
+-- | A POST whose DATA frame claims 100 octets and has 10 when the stream
+-- ends (RFC 9114, section 7.1: a connection error of type H3_FRAME_ERROR).
+-- Written by hand on a stream of its own, the HEADERS frame referring to the
+-- static table only.
+runTruncatedFrameClient :: IO ()
+runTruncatedFrameClient = do
+    r <- timeout 3000000 $ E.try $ QUIC.run testClientConfig $ \conn ->
+        E.bracket allocSimpleConfig freeSimpleConfig $ \conf ->
+            C.run conn testH3ClientConfig conf $ \_sendRequest _aux -> do
+                strm <- Q.stream conn
+                Q.sendStream strm $ headersFrame <> dataPart
+                Q.shutdownStream strm
+                threadDelay 1000000
+    case r of
+        Just (Left (Q.ApplicationProtocolErrorIsReceived H3FrameError _)) -> return ()
+        _ -> expectationFailure $ "unexpected: " ++ show r
+  where
+    -- Required Insert Count 0 and Base 0, then :method POST (static 20),
+    -- :scheme https (23), :path with "/drain", :authority with "localhost".
+    section =
+        B.pack [0x00, 0x00, 0xd4, 0xd7, 0x51, 6]
+            <> "/drain"
+            <> B.pack [0x50, 9]
+            <> "localhost"
+    headersFrame = B.pack [0x01, fromIntegral (B.length section)] <> section
+    dataPart = B.pack [0x00, 0x40, 100] <> B.replicate 10 0x61
 
 -- | The client's QPACK decoder stream: its third unidirectional stream, after
 -- the control stream and the encoder stream.
diff --git a/test/QPACK/HeaderBlockSpec.hs b/test/QPACK/HeaderBlockSpec.hs
--- a/test/QPACK/HeaderBlockSpec.hs
+++ b/test/QPACK/HeaderBlockSpec.hs
@@ -5,6 +5,7 @@
 import Control.Concurrent (threadDelay)
 import Control.Concurrent.Async
 import Control.Concurrent.STM
+import qualified Control.Exception as E
 import Control.Monad
 import qualified Data.ByteString as BS
 import qualified Data.ByteString.Char8 as C8
@@ -15,6 +16,7 @@
 import Network.QPACK.Internal (
     DecoderInstruction (..),
     EncoderInstruction (..),
+    EncoderInstructionError (..),
     encodeDecoderInstructions,
     encodeEncoderInstructions,
  )
@@ -33,15 +35,66 @@
             mapM_ roundTrip [1000, 2100, 8000, 20000]
 
         it "encodes a field larger than the encoder's buffers" $ do
-            -- With the default 4096-octet buffers.  4083 is the value that
+            -- With the default 4096-octet buffers.  1011 is the value that
             -- makes the field come to exactly the buffer by the encoder's
-            -- estimate, which used to loop forever; anything larger was
-            -- refused with BufferOverrun.  100000 is also long enough for
-            -- its Huffman code to need a four-octet length.
-            forM_ [4083, 4084, 10000, 100000] $ \n -> do
+            -- estimate ('tokenHeaderSize': four times the octets, plus 32);
+            -- one like it used to loop forever, and anything larger was
+            -- refused with BufferOverrun.  4083 was that value when the
+            -- estimate was the raw length plus 8.  100000 is also long
+            -- enough for its Huffman code to need a four-octet length.
+            forM_ [1011, 1012, 4083, 4084, 10000, 100000] $ \n -> do
                 r <- timeout 5000000 $ roundTripWith defaultQEncoderConfig n
                 void r `shouldBe` Just ()
 
+        it "keeps the tables in step when Huffman makes a value longer" $ do
+            -- The encoder sized its chunks by the raw length of the fields,
+            -- but Huffman coding, tried in place, makes octets above 0x7f
+            -- three to four times longer.  A value of 1500 of them overran
+            -- the 4096-octet buffers after an entry earlier in the chunk had
+            -- gone into the encoder's table, its insertion still unsent: the
+            -- next section referred to an entry the decoder never got, and
+            -- waited for it for ever.
+            r <- timeout 5000000 $ withConnectedPair $ \enc dec -> do
+                let small = [(toToken "x-a", "foo")]
+                    big n = small ++ [(toToken "x-b", BS.replicate n 0xff)]
+                    sections = [small, big 1500, small] ++ map big [500, 1000 .. 6000] ++ [small]
+                forM_ (zip [0, 4 ..] sections) $ \(sid, hdr) -> do
+                    blk <- enc sid hdr
+                    (ths, _) <- dec sid blk
+                    ths `shouldBe` hdr
+            r `shouldBe` Just ()
+
+        it "refuses at once an encoder instruction too long to be valid" $ do
+            -- Its length says it inserts an entry larger than any capacity
+            -- the decoder allows.  It used to be decoded again from its
+            -- beginning with each piece of it that arrived, and kept for as
+            -- long as the peer went on.
+            -- Insert with literal name "x", not in Huffman, its value
+            -- declared to be 1000000 octets: 127 and the rest in 7-bit
+            -- groups.
+            let ei =
+                    BS.pack ([0x41, 0x78, 0x7f] ++ varint (1000000 - 127 :: Int))
+                        <> BS.replicate 10000 0x61
+                varint n
+                    | n < 128 = [fromIntegral n]
+                    | otherwise = fromIntegral (n `mod` 128 + 128) : varint (n `div` 128)
+            ref <- newIORef $ BS.take 10000 ei
+            let recv n = atomicModifyIORef' ref $ \bs -> let (a, b) = BS.splitAt n bs in (b, a)
+            (_, handleEI) <- newQDecoder defaultQDecoderConfig (\_ -> return ())
+            r <- timeout 1000000 $ E.try $ handleEI recv
+            r `shouldBe` Just (Left EncoderInstructionError)
+
+        it "takes encoder instructions arriving an octet at a time" $ do
+            -- Each is decoded once it is all there.  A value of 4000 octets is
+            -- near what the default capacity of 4096 takes.
+            r <- timeout 5000000 $ withConnectedPairBy 1 $ \enc dec ->
+                forM_ (zip [0, 4 ..] [100, 100, 4000, 4000]) $ \(sid, n) -> do
+                    let hdr = [(toToken "x-big", C8.replicate n 'a')]
+                    blk <- enc sid hdr
+                    (ths, _) <- dec sid blk
+                    ths `shouldBe` hdr
+            r `shouldBe` Just ()
+
         it "agrees with the decoder on MaxEntries when the encoder uses less" $ do
             -- The decoder offers 64K, the encoder settles for 4K.  Required
             -- Insert Count is encoded against the 64K both ends know about;
@@ -297,6 +350,36 @@
     (ths, _) <- dec 0 blk
     lookup (toToken "x-big") ths `shouldBe` Just val
     return blk
+
+-- | An encoder and a decoder with the default configurations, each one's
+-- instructions carried to the other.
+withConnectedPair :: (QEncoder -> QDecoder -> IO a) -> IO a
+withConnectedPair = withConnectedPairBy maxBound
+
+-- | Like 'withConnectedPair', the encoder's instructions reaching the
+-- decoder no more than the given number of octets at a time.
+withConnectedPairBy :: Int -> (QEncoder -> QDecoder -> IO a) -> IO a
+withConnectedPairBy chunk body = do
+    eiQ <- newTQueueIO
+    diQ <- newTQueueIO
+    (enc, handleDI, tblop) <-
+        newQEncoder defaultQEncoderConfig (atomically . writeTQueue eiQ)
+    (dec, handleEI) <-
+        newQDecoder defaultQDecoderConfig (atomically . writeTQueue diQ)
+    -- What the decoder's SETTINGS would carry.
+    setCapacity tblop $ dcMaxTableCapacity defaultQDecoderConfig
+    setBlockedStreams tblop 100
+    pendingEI <- newIORef ""
+    let recvFrom q _ = atomically $ readTQueue q
+        recvEI n = do
+            bs0 <- readIORef pendingEI
+            bs <- if BS.null bs0 then atomically (readTQueue eiQ) else return bs0
+            let (a, b) = BS.splitAt (min n chunk) bs
+            writeIORef pendingEI b
+            return a
+    withAsync (handleEI recvEI) $ \_ ->
+        withAsync (handleDI $ recvFrom diQ) $ \_ ->
+            body enc dec
 
 roundTripMany :: Int -> Int -> Int -> IO ()
 roundTripMany encCap decCap n = do
diff --git a/test/QPACK/InstructionSpec.hs b/test/QPACK/InstructionSpec.hs
--- a/test/QPACK/InstructionSpec.hs
+++ b/test/QPACK/InstructionSpec.hs
@@ -2,7 +2,8 @@
 
 module QPACK.InstructionSpec where
 
-import Data.ByteString ()
+import Control.Monad (forM_)
+import qualified Data.ByteString as BS
 import Network.HTTP.Semantics
 import Network.QPACK.Internal
 import Test.Hspec
@@ -23,6 +24,29 @@
             bs2 <- encodeEncoderInstructions eis0 False
             (eis2, "") <- decodeEncoderInstructions' bs2
             eis2 `shouldBe` eis0
+    describe "encoderInstructionLength" $ do
+        it "tells the length of the first instruction once it can" $ do
+            let eis =
+                    [ SetDynamicTableCapacity 4096
+                    , SetDynamicTableCapacity 1000000
+                    , InsertWithNameReference (Left 92) "Warp/4.3.2.1"
+                    , InsertWithNameReference (Right 1000) (BS.replicate 300 0xff)
+                    , InsertWithLiteralName tokenContentType "text/plain"
+                    , InsertWithLiteralName tokenContentType (BS.replicate 3000 0x61)
+                    , Duplicate 40
+                    , Duplicate 100000
+                    ]
+            forM_ [True, False] $ \huff ->
+                forM_ eis $ \ei -> do
+                    e <- encodeEncoderInstructions [ei] huff
+                    next <- encodeEncoderInstructions [Duplicate 1] huff
+                    let len = BS.length e
+                    encoderInstructionLength e `shouldBe` Just len
+                    encoderInstructionLength (e <> next) `shouldBe` Just len
+                    -- Short of it: not yet known, or known right.
+                    forM_ [0 .. len - 1] $ \k ->
+                        encoderInstructionLength (BS.take k e)
+                            `shouldSatisfy` maybe True (== len)
     describe "encodeDecoderInstructions and decodeDecoderInstructions" $ do
         it "encode/decodes decode instructions properly" $ do
             let eis0 =
diff --git a/test/QPACK/TableSpec.hs b/test/QPACK/TableSpec.hs
--- a/test/QPACK/TableSpec.hs
+++ b/test/QPACK/TableSpec.hs
@@ -1,7 +1,14 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
 module QPACK.TableSpec where
 
 import Control.Concurrent.STM
 import qualified Control.Exception as E
+import Control.Monad (forM_, replicateM_)
+import qualified Data.ByteString as BS
+import Network.HPACK.Internal (toEntryToken)
+import Network.HPACK.Token (toToken)
 import Network.QPACK.Internal
 import Test.Hspec
 import Test.Hspec.QuickCheck
@@ -24,6 +31,47 @@
             look 1000 `shouldReturn` Left (IllegalStaticIndex 1000)
             look (-1) `shouldReturn` Left (IllegalStaticIndex (-1))
 
+    describe "the decoder's table" $ do
+        -- An entry is 32 octets more than its name and value: "x" and n
+        -- octets of value come to n + 33.
+        let entry n = toEntryToken (toToken "x") (BS.replicate n 0x61)
+            -- MaxEntries from an announced capacity of 4096, and the
+            -- capacity the encoder set.
+            newTable cap = do
+                dyntbl <- newDynamicTableForDecoding 2048 (\_ -> return ())
+                setMaxEntries dyntbl 4096
+                setDecoderTableCapacity dyntbl cap
+                return dyntbl
+            insert dyntbl n = atomically $ insertEntryToDecoder (entry n) dyntbl
+            present dyntbl i = do
+                r <- E.try $ atomically (toDynamicEntry dyntbl (AbsoluteIndex i)) >>= E.evaluate
+                return $ either (\(_ :: E.SomeException) -> False) (const True) r
+        it "refuses an entry larger than the capacity" $ do
+            dyntbl <- newTable 100
+            insert dyntbl 68 `shouldThrow` (== EncoderInstructionError)
+            -- One that fits exactly is taken.
+            _ <- insert dyntbl 67
+            getTableCapacity dyntbl `shouldReturn` 100
+        it "evicts the oldest entries to make room" $ do
+            dyntbl <- newTable 100
+            -- 60 octets each: only one fits.
+            replicateM_ 2 $ insert dyntbl 27
+            present dyntbl 0 `shouldReturn` False
+            present dyntbl 1 `shouldReturn` True
+            getTableCapacity dyntbl `shouldReturn` 60
+        it "evicts what no longer fits when the capacity is lowered" $ do
+            dyntbl <- newTable 4096
+            replicateM_ 3 $ insert dyntbl 27
+            setDecoderTableCapacity dyntbl 100
+            mapM (present dyntbl) [0, 1, 2] `shouldReturn` [False, False, True]
+            getTableCapacity dyntbl `shouldReturn` 60
+        it "holds no more than the capacity whatever is inserted" $ do
+            -- A slot for each 32 octets of 4096, and entries of 4000 octets:
+            -- the ring took 128 of them, half a megabyte.
+            dyntbl <- newTable 4096
+            forM_ [1 .. 200 :: Int] $ \_ -> insert dyntbl 4000
+            getTableCapacity dyntbl >>= (`shouldSatisfy` (<= 4096))
+            mapM (present dyntbl) [198, 199] `shouldReturn` [False, True]
     describe "encodeRequiredInsertCount and decodeRequiredInsertCount" $ do
         prop "duality" $ \(Triple m ei di) -> do
             let ereq = encodeRequiredInsertCount m (RequiredInsertCount ei)
