Repository: bearcove/loona (language: rust)

Issue:
httpwg: Headers can't be a `MultiMap`
...because, when testing, we need to preserve the order in which we inserted headers. we should have a "replace", and we should prepend (so, VecDeque?) when adding pseudo-headers, but that's it.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 16524b77..aa3d00ec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -526,7 +526,6 @@ dependencies = [
  "fluke-h2-parse",
  "fluke-hpack",
  "futures-util",
- "multimap",
  "pretty-hex",
  "tokio",
  "tracing",
@@ -734,12 +733,6 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
-[[package]]
-name = "multimap"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
-
 [[package]]
 name = "nix"
 version = "0.27.1"
diff --git a/crates/fluke-buffet/src/io.rs b/crates/fluke-buffet/src/io.rs
index 92c60156..0dbb9486 100644
--- a/crates/fluke-buffet/src/io.rs
+++ b/crates/fluke-buffet/src/io.rs
@@ -16,7 +16,8 @@ pub trait WriteOwned {
     /// Might perform a partial write, see [WriteOwned::write_all]
     async fn write_owned(&mut self, buf: impl Into<Piece>) -> BufResult<usize, Piece>;
 
-    /// Write a single buffer, re-trying the write if the kernel does a partial write.
+    /// Write a single buffer, re-trying the write if the kernel does a partial
+    /// write.
     async fn write_all_owned(&mut self, buf: impl Into<Piece>) -> std::io::Result<()> {
         let mut buf = buf.into();
         let mut written = 0;
@@ -68,7 +69,8 @@ pub trait WriteOwned {
         Ok(total)
     }
 
-    /// Write a list of buffers, re-trying the write if the kernel does a partial write.
+    /// Write a list of buffers, re-trying the write if the kernel does a
+    /// partial write.
     async fn writev_all_owned(&mut self, mut list: PieceList) -> std::io::Result<()> {
         while !list.is_empty() {
             let n = self.writev_owned(&list).await?;
@@ -192,7 +194,7 @@ mod tests {
     }
 }
 
-pub trait IntoHalves {
+pub trait IntoHalves: 'static {
     type Read: ReadOwned;
     type Write: WriteOwned;
 
diff --git a/crates/fluke-buffet/src/piece.rs b/crates/fluke-buffet/src/piece.rs
index f473bcbe..ead271cd 100644
--- a/crates/fluke-buffet/src/piece.rs
+++ b/crates/fluke-buffet/src/piece.rs
@@ -1,13 +1,20 @@
 //! Types for performing vectored I/O.
 
 use http::header::HeaderName;
-use std::{collections::VecDeque, fmt, ops::Deref, rc::Rc, str::Utf8Error};
+use std::{
+    collections::VecDeque,
+    fmt,
+    hash::{Hash, Hasher},
+    ops::Deref,
+    rc::Rc,
+    str::Utf8Error,
+};
 
 use crate::{Roll, RollStr};
 
 /// A piece of data (arbitrary bytes) with a stable address, suitable for
 /// passing to the kernel (io_uring writes).
-#[derive(Clone, PartialEq, Eq, Hash)]
+#[derive(Clone)]
 pub enum Piece {
     Full {
         core: PieceCore,
@@ -19,6 +26,21 @@ pub enum Piece {
     },
 }
 
+impl<T: AsRef<[u8]>> PartialEq<T> for Piece {
+    fn eq(&self, other: &T) -> bool {
+        let s: &[u8] = self.as_ref();
+        s.eq(other.as_ref())
+    }
+}
+
+impl Eq for Piece {}
+
+impl Hash for Piece {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.as_ref().hash(state)
+    }
+}
+
 impl Piece {
     /// Returns an empty piece
     pub fn empty() -> Self {
@@ -28,7 +50,7 @@ impl Piece {
     }
 }
 
-#[derive(Clone, PartialEq, Eq, Hash)]
+#[derive(Clone, Hash)]
 pub enum PieceCore {
     Static(&'static [u8]),
     Vec(Rc<Vec<u8>>),
@@ -298,6 +320,14 @@ pub struct PieceStr {
     piece: Piece,
 }
 
+impl PartialEq for PieceStr {
+    fn eq(&self, other: &Self) -> bool {
+        self.piece == other.piece
+    }
+}
+
+impl Eq for PieceStr {}
+
 impl fmt::Debug for PieceStr {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
         fmt::Debug::fmt(&self[..], f)
diff --git a/crates/fluke-h2-parse/src/lib.rs b/crates/fluke-h2-parse/src/lib.rs
index 426178b0..9c6c60b1 100644
--- a/crates/fluke-h2-parse/src/lib.rs
+++ b/crates/fluke-h2-parse/src/lib.rs
@@ -375,20 +375,30 @@ impl Frame {
         Ok(())
     }
 
-    /// Returns true if this  frame is an ack
+    /// Returns true if this frame is an ack
     pub fn is_ack(self) -> bool {
         match self.frame_type {
-            FrameType::Data(_) => false,
-            FrameType::Headers(_) => false,
-            FrameType::Priority => false,
-            FrameType::RstStream => false,
             FrameType::Settings(flags) => flags.contains(SettingsFlags::Ack),
-            FrameType::PushPromise => false,
             FrameType::Ping(flags) => flags.contains(PingFlags::Ack),
-            FrameType::GoAway => false,
-            FrameType::WindowUpdate => false,
-            FrameType::Continuation(_) => false,
-            FrameType::Unknown(_) => false,
+            _ => false,
+        }
+    }
+
+    /// Returns true if this frame has `EndHeaders` set
+    pub fn is_end_headers(self) -> bool {
+        match self.frame_type {
+            FrameType::Headers(flags) => flags.contains(HeadersFlags::EndHeaders),
+            FrameType::Continuation(flags) => flags.contains(ContinuationFlags::EndHeaders),
+            _ => false,
+        }
+    }
+
+    /// Returns true if this frame has `EndStream` set
+    pub fn is_end_stream(self) -> bool {
+        match self.frame_type {
+            FrameType::Data(flags) => flags.contains(DataFlags::EndStream),
+            FrameType::Headers(flags) => flags.contains(HeadersFlags::EndStream),
+            _ => false,
         }
     }
 }
diff --git a/crates/fluke-hpack/Cargo.toml b/crates/fluke-hpack/Cargo.toml
index 0c705968..fe6454a4 100644
--- a/crates/fluke-hpack/Cargo.toml
+++ b/crates/fluke-hpack/Cargo.toml
@@ -27,4 +27,5 @@ hex = "0.4.3"
 thiserror = "1.0.58"
 
 [dependencies]
+thiserror = "1.0.58"
 tracing = "0.1"
diff --git a/crates/fluke-hpack/src/decoder.rs b/crates/fluke-hpack/src/decoder.rs
index 33ad76e5..aa55315c 100644
--- a/crates/fluke-hpack/src/decoder.rs
+++ b/crates/fluke-hpack/src/decoder.rs
@@ -21,8 +21,9 @@
 //! ]);
 //! ```
 //!
-//! A more complex example where the callback API is used, providing the client a
-//! borrowed representation of each header, rather than an owned representation.
+//! A more complex example where the callback API is used, providing the client
+//! a borrowed representation of each header, rather than an owned
+//! representation.
 //!
 //! ```rust
 //! use fluke_hpack::Decoder;
@@ -48,7 +49,7 @@
 use std::borrow::Cow;
 use std::num::Wrapping;
 
-use tracing::{debug, trace};
+use tracing::trace;
 
 use super::huffman::HuffmanDecoder;
 use super::huffman::HuffmanDecoderError;
@@ -133,7 +134,7 @@ fn decode_integer(buf: &[u8], prefix_size: u8) -> Result<(usize, usize), Decoder
 /// bytes consumed from the given buffer.
 fn decode_string(buf: &[u8]) -> Result<(Cow<'_, [u8]>, usize), DecoderError> {
     let (len, consumed) = decode_integer(buf, 7)?;
-    debug!("decode_string: Consumed = {}, len = {}", consumed, len);
+    trace!("decode_string: Consumed = {}, len = {}", consumed, len);
     if consumed + len > buf.len() {
         return Err(DecoderError::StringDecodingError(
             StringDecodingError::NotEnoughOctets,
@@ -141,7 +142,7 @@ fn decode_string(buf: &[u8]) -> Result<(Cow<'_, [u8]>, usize), DecoderError> {
     }
     let raw_string = &buf[consumed..consumed + len];
     if buf[0] & 128 == 128 {
-        debug!("decode_string: Using the Huffman code");
+        trace!("decode_string: Using the Huffman code");
         // Huffman coding used: pass the raw octets to the Huffman decoder
         // and return its result.
         let mut decoder = HuffmanDecoder::new();
@@ -156,7 +157,7 @@ fn decode_string(buf: &[u8]) -> Result<(Cow<'_, [u8]>, usize), DecoderError> {
         Ok((Cow::Owned(decoded), consumed + len))
     } else {
         // The octets were transmitted raw
-        debug!("decode_string: Raw octet string received");
+        trace!("decode_string: Raw octet string received");
         Ok((Cow::Borrowed(raw_string), consumed + len))
     }
 }
@@ -198,47 +199,69 @@ impl FieldRepresentation {
 
 /// Represents all errors that can be encountered while decoding an
 /// integer.
-#[derive(PartialEq, Copy, Clone, Debug)]
+#[derive(PartialEq, Copy, Clone, Debug, thiserror::Error)]
 pub enum IntegerDecodingError {
     /// 5.1. specifies that "excessively large integer decodings" MUST be
     /// considered an error (whether the size is the number of octets or
     /// value). This variant corresponds to the encoding containing too many
     /// octets.
+    #[error("Too many octets in the integer encoding")]
     TooManyOctets,
     /// The variant corresponds to the case where the value of the integer
     /// being decoded exceeds a certain threshold.
+    #[error("Integer value too large")]
     ValueTooLarge,
     /// When a buffer from which an integer was supposed to be encoded does
     /// not contain enough octets to complete the decoding.
+    #[error("Not enough octets in the buffer")]
     NotEnoughOctets,
     /// Only valid prefixes are [1, 8]
+    #[error("Invalid prefix")]
     InvalidPrefix,
 }
 
-/// Represents all errors that can be encountered while decoding an octet
-/// string.
-#[derive(PartialEq, Copy, Clone, Debug)]
+#[derive(PartialEq, Copy, Clone, Debug, thiserror::Error)]
 pub enum StringDecodingError {
+    #[error("Not enough octets in the buffer")]
     NotEnoughOctets,
+    #[error("Huffman decoder error: {0}")]
     HuffmanDecoderError(HuffmanDecoderError),
 }
 
 /// Represents all errors that can be encountered while performing the decoding
 /// of an HPACK header set.
-#[derive(PartialEq, Copy, Clone, Debug)]
+#[derive(PartialEq, Copy, Clone, Debug, thiserror::Error)]
 pub enum DecoderError {
+    #[error("Header index out of bounds")]
     HeaderIndexOutOfBounds,
+    #[error("Integer decoding error: {0}")]
     IntegerDecodingError(IntegerDecodingError),
+    #[error("String decoding error: {0}")]
     StringDecodingError(StringDecodingError),
     /// The size of the dynamic table can never be allowed to exceed the max
     /// size mandated to the decoder by the protocol. (by performing changes
     /// made by SizeUpdate blocks).
+    #[error("Dynamic table size exceeds the maximum size")]
     InvalidMaxDynamicSize,
     /// A header block cannot end with a dynamic table size update, it
     /// must be treating as a decoding error.
+    #[error("Dynamic table size update at the end of a header block")]
     SizeUpdateAtEnd,
 }
 
+/// Represents all errors that can be encountered while performing the decoding
+/// of an HPACK header set, or while invoking the callback.
+pub enum DecoderOrCallbackError<E> {
+    DecoderError(DecoderError),
+    CallbackError(E),
+}
+
+impl<E> From<DecoderError> for DecoderOrCallbackError<E> {
+    fn from(err: DecoderError) -> Self {
+        Self::DecoderError(err)
+    }
+}
+
 /// The result returned by the `decode` method of the `Decoder`.
 pub type DecoderResult = Result<Vec<(Vec<u8>, Vec<u8>)>, DecoderError>;
 
@@ -320,25 +343,30 @@ impl<'a> Decoder<'a> {
         self.max_allowed_table_size = Some(max_allowed_size);
     }
 
-    /// Decodes the headers found in the given buffer `buf`. Invokes the callback `cb` for each
-    /// decoded header in turn, by providing it the header name and value as `Cow` byte array
-    /// slices.
+    /// Decodes the headers found in the given buffer `buf`. Invokes the
+    /// callback `cb` for each decoded header in turn, by providing it the
+    /// header name and value as `Cow` byte array slices.
     ///
-    /// The callback is free to decide how to handle the emitted header, however the `Cow` cannot
-    /// outlive the closure body without assuming ownership or otherwise copying the contents.
+    /// The callback is free to decide how to handle the emitted header, however
+    /// the `Cow` cannot outlive the closure body without assuming ownership
+    /// or otherwise copying the contents.
     ///
-    /// This is due to the fact that the header might be found (fully or partially) in the header
-    /// table of the decoder, in which case the callback will have received a borrow of its
-    /// contents. However, when one of the following headers is decoded, it is possible that the
-    /// header table might have to be modified; so the borrow is only valid until the next header
-    /// decoding begins, meaning until the end of the callback's body.
+    /// This is due to the fact that the header might be found (fully or
+    /// partially) in the header table of the decoder, in which case the
+    /// callback will have received a borrow of its contents. However, when
+    /// one of the following headers is decoded, it is possible that the
+    /// header table might have to be modified; so the borrow is only valid
+    /// until the next header decoding begins, meaning until the end of the
+    /// callback's body.
     ///
-    /// If an error is encountered during the decoding of any header, decoding halts and the
-    /// appropriate error is returned as the `Err` variant of the `Result`.
-    pub fn decode_with_cb<F>(&mut self, buf: &[u8], mut cb: F) -> Result<(), DecoderError>
-    where
-        F: FnMut(Cow<[u8]>, Cow<[u8]>),
-    {
+    /// If an error is encountered during the decoding of any header, decoding
+    /// halts and the appropriate error is returned as the `Err` variant of
+    /// the `Result`.
+    pub fn decode_with_cb(
+        &mut self,
+        buf: &[u8],
+        mut cb: impl FnMut(Cow<[u8]>, Cow<[u8]>),
+    ) -> Result<(), DecoderError> {
         let mut current_octet_index = 0;
 
         let mut last_was_size_update = false;
@@ -420,8 +448,9 @@ impl<'a> Decoder<'a> {
 
     /// Decode the header block found in the given buffer.
     ///
-    /// The decoded representation is returned as a sequence of headers, where both the name and
-    /// value of each header is represented by an owned byte sequence (i.e. `Vec<u8>`).
+    /// The decoded representation is returned as a sequence of headers, where
+    /// both the name and value of each header is represented by an owned
+    /// byte sequence (i.e. `Vec<u8>`).
     ///
     /// The buffer should represent the entire block that should be decoded.
     /// For example, in HTTP/2, all continuation frames need to be concatenated
@@ -439,9 +468,10 @@ impl<'a> Decoder<'a> {
     /// Decodes an indexed header representation.
     fn decode_indexed(&self, buf: &[u8]) -> Result<DecodedLiteralSlice<'_>, DecoderError> {
         let (index, consumed) = decode_integer(buf, 7)?;
-        debug!(
+        trace!(
             "Decoding indexed: index = {}, consumed = {}",
-            index, consumed
+            index,
+            consumed
         );
 
         let (name, value) = self.get_from_table(index)?;
@@ -698,7 +728,8 @@ mod tests {
 
     #[test]
     fn test_decode_string_no_huffman() {
-        /// Checks that the result matches the expectation, but also that the `Cow` is borrowed!
+        /// Checks that the result matches the expectation, but also that the
+        /// `Cow` is borrowed!
         fn assert_borrowed_eq(expected: (&[u8], usize), result: (Cow<'_, [u8]>, usize)) {
             let (expected_str, expected_len) = expected;
             let (actual_str, actual_len) = result;
@@ -1652,7 +1683,7 @@ mod interop_tests {
 
         for fixture in files {
             let file_name = fixture.unwrap().path();
-            debug!("Testing fixture: {:?}", file_name);
+            trace!("Testing fixture: {:?}", file_name);
             test_story(file_name);
         }
     }
diff --git a/crates/fluke-hpack/src/huffman.rs b/crates/fluke-hpack/src/huffman.rs
index 2e2b0aaa..44fd9904 100644
--- a/crates/fluke-hpack/src/huffman.rs
+++ b/crates/fluke-hpack/src/huffman.rs
@@ -25,15 +25,17 @@ impl HuffmanCodeSymbol {
     }
 }
 
-/// Represents the error variants that the `HuffmanDecoder` can return.
-#[derive(PartialEq, Copy, Clone, Debug)]
+#[derive(thiserror::Error, PartialEq, Copy, Clone, Debug)]
 pub enum HuffmanDecoderError {
     /// Any padding strictly larger than 7 bits MUST be interpreted as an error
+    #[error("Padding too large")]
     PaddingTooLarge,
     /// Any padding that does not correspond to the most significant bits of
     /// EOS MUST be interpreted as an error.
+    #[error("Invalid padding")]
     InvalidPadding,
     /// If EOS is ever found in the string, it causes an error.
+    #[error("EOS in string")]
     EOSInString,
 }
 
diff --git a/crates/fluke/src/h1/body.rs b/crates/fluke/src/h1/body.rs
index f58ca6bc..010a2548 100644
--- a/crates/fluke/src/h1/body.rs
+++ b/crates/fluke/src/h1/body.rs
@@ -3,9 +3,7 @@ use std::fmt;
 use tracing::debug;
 
 use crate::{util::read_and_parse, Body, BodyChunk, BodyErrorReason};
-use fluke_buffet::{
-    PieceCore, PieceList, RollMut, {ReadOwned, WriteOwned},
-};
+use fluke_buffet::{Piece, PieceList, ReadOwned, RollMut, WriteOwned};
 
 /// An HTTP/1.1 body, either chunked or content-length.
 pub(crate) struct H1Body<T> {
@@ -267,7 +265,7 @@ pub(crate) async fn write_h1_body(
 
 pub(crate) async fn write_h1_body_chunk(
     transport: &mut impl WriteOwned,
-    chunk: PieceCore,
+    chunk: Piece,
     mode: BodyWriteMode,
 ) -> eyre::Result<()> {
     match mode {
diff --git a/crates/fluke/src/h1/encode.rs b/crates/fluke/src/h1/encode.rs
index 5f6f5226..fa550351 100644
--- a/crates/fluke/src/h1/encode.rs
+++ b/crates/fluke/src/h1/encode.rs
@@ -7,7 +7,7 @@ use crate::{
     types::{Headers, Request, Response},
     Encoder,
 };
-use fluke_buffet::{PieceCore, PieceList, RollMut, WriteOwned};
+use fluke_buffet::{Piece, PieceList, RollMut, WriteOwned};
 
 use super::body::{write_h1_body_chunk, write_h1_body_end, BodyWriteMode};
 
@@ -167,11 +167,7 @@ where
     }
 
     // TODO: move `mode` into `H1Encoder`? we don't need it for h2
-    async fn write_body_chunk(
-        &mut self,
-        chunk: PieceCore,
-        mode: BodyWriteMode,
-    ) -> eyre::Result<()> {
+    async fn write_body_chunk(&mut self, chunk: Piece, mode: BodyWriteMode) -> eyre::Result<()> {
         // TODO: inline
         write_h1_body_chunk(&mut self.transport_w, chunk, mode).await
     }
diff --git a/crates/fluke/src/h1/server.rs b/crates/fluke/src/h1/server.rs
index 267fa9b4..4ed521ae 100644
--- a/crates/fluke/src/h1/server.rs
+++ b/crates/fluke/src/h1/server.rs
@@ -6,7 +6,7 @@ use tracing::debug;
 use crate::{
     h1::body::{H1Body, H1BodyKind},
     util::{read_and_parse, SemanticError},
-    ExpectResponseHeaders, HeadersExt, Responder, ServerDriver,
+    HeadersExt, Responder, ServerDriver,
 };
 use fluke_buffet::{ReadOwned, RollMut, WriteOwned};
 
@@ -93,10 +93,7 @@ pub async fn serve(
             },
         );
 
-        let responder = Responder {
-            encoder: H1Encoder { transport_w },
-            state: ExpectResponseHeaders,
-        };
+        let responder = Responder::new(H1Encoder { transport_w });
 
         let resp = driver
             .handle(req, &mut req_body, responder)
diff --git a/crates/fluke/src/h2/body.rs b/crates/fluke/src/h2/body.rs
index dfed995c..422e4cb1 100644
--- a/crates/fluke/src/h2/body.rs
+++ b/crates/fluke/src/h2/body.rs
@@ -1,10 +1,12 @@
+use core::fmt;
+
 use tokio::sync::mpsc;
 
 use crate::{Body, BodyChunk, Headers};
-use fluke_buffet::PieceCore;
+use fluke_buffet::Piece;
 
 pub(crate) enum PieceOrTrailers {
-    Piece(PieceCore),
+    Piece(Piece),
     Trailers(Box<Headers>),
 }
 
@@ -61,3 +63,55 @@ impl Body for H2Body {
         Ok(chunk)
     }
 }
+
+pub(crate) struct SinglePieceBody {
+    content_len: u64,
+    piece: Option<Piece>,
+}
+
+impl fmt::Debug for SinglePieceBody {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut debug_struct = f.debug_struct("SinglePieceBody");
+        debug_struct.field("content_len", &self.content_len);
+
+        if let Some(piece) = &self.piece {
+            match std::str::from_utf8(piece.as_ref()) {
+                Ok(utf8_str) => debug_struct.field("piece", &utf8_str),
+                Err(_) => debug_struct.field("piece", &"(non-utf8 string)"),
+            };
+        } else {
+            debug_struct.field("piece", &"(none)");
+        }
+
+        debug_struct.finish()
+    }
+}
+
+impl SinglePieceBody {
+    pub(crate) fn new(piece: Piece) -> Self {
+        let content_len = piece.len() as u64;
+        Self {
+            content_len,
+            piece: Some(piece),
+        }
+    }
+}
+
+impl Body for SinglePieceBody {
+    fn content_len(&self) -> Option<u64> {
+        self.piece.as_ref().map(|piece| piece.len() as u64)
+    }
+
+    fn eof(&self) -> bool {
+        self.piece.is_none()
+    }
+
+    async fn next_chunk(&mut self) -> eyre::Result<BodyChunk> {
+        tracing::trace!( has_piece = %self.piece.is_some(), "SinglePieceBody::next_chunk");
+        if let Some(piece) = self.piece.take() {
+            Ok(BodyChunk::Chunk(piece))
+        } else {
+            Ok(BodyChunk::Done { trailers: None })
+        }
+    }
+}
diff --git a/crates/fluke/src/h2/encode.rs b/crates/fluke/src/h2/encode.rs
index 06a5f212..fb8ce56f 100644
--- a/crates/fluke/src/h2/encode.rs
+++ b/crates/fluke/src/h2/encode.rs
@@ -1,3 +1,4 @@
+use fluke_buffet::Piece;
 use http::{StatusCode, Version};
 use tokio::sync::mpsc;
 use tracing::debug;
@@ -13,13 +14,22 @@ pub(crate) enum EncoderState {
     ResponseDone,
 }
 
-pub struct H2Encoder {
-    pub(crate) stream_id: StreamId,
-    pub(crate) tx: mpsc::Sender<H2Event>,
-    pub(crate) state: EncoderState,
+/// Encodes HTTP/2 responses and bodies
+pub(crate) struct H2Encoder {
+    stream_id: StreamId,
+    tx: mpsc::Sender<H2Event>,
+    state: EncoderState,
 }
 
 impl H2Encoder {
+    pub(crate) fn new(stream_id: StreamId, tx: mpsc::Sender<H2Event>) -> Self {
+        Self {
+            stream_id,
+            tx,
+            state: EncoderState::ExpectResponseHeaders,
+        }
+    }
+
     fn event(&self, payload: H2EventPayload) -> H2Event {
         H2Event {
             payload,
@@ -54,11 +64,7 @@ impl Encoder for H2Encoder {
     }
 
     // TODO: BodyWriteMode is not relevant for h2
-    async fn write_body_chunk(
-        &mut self,
-        chunk: fluke_buffet::PieceCore,
-        _mode: BodyWriteMode,
-    ) -> eyre::Result<()> {
+    async fn write_body_chunk(&mut self, chunk: Piece, _mode: BodyWriteMode) -> eyre::Result<()> {
         assert!(matches!(self.state, EncoderState::ExpectResponseBody));
 
         self.send(H2EventPayload::BodyChunk(chunk)).await?;
diff --git a/crates/fluke/src/h2/server.rs b/crates/fluke/src/h2/server.rs
index 43f4a3e3..579711e5 100644
--- a/crates/fluke/src/h2/server.rs
+++ b/crates/fluke/src/h2/server.rs
@@ -17,7 +17,7 @@ use fluke_h2_parse::{
 use http::{
     header,
     uri::{Authority, PathAndQuery, Scheme},
-    HeaderName, Version,
+    HeaderName, StatusCode, Version,
 };
 use parse::IntoPiece;
 use smallvec::{smallvec, SmallVec};
@@ -27,16 +27,18 @@ use tracing::{debug, trace};
 use crate::{
     h2::{
         body::{H2Body, PieceOrTrailers, StreamIncoming, StreamIncomingItem},
-        encode::{EncoderState, H2Encoder},
+        encode::H2Encoder,
         types::{
-            BodyOutgoing, ConnState, H2ConnectionError, H2Event, H2EventPayload, H2StreamError,
-            HeadersOrTrailers, HeadersOutgoing, StreamOutgoing, StreamState,
+            BodyOutgoing, ConnState, H2ConnectionError, H2Event, H2EventPayload, H2RequestError,
+            H2StreamError, HeadersOrTrailers, HeadersOutgoing, StreamOutgoing, StreamState,
         },
     },
     util::read_and_parse,
-    ExpectResponseHeaders, Headers, Method, Request, Responder, ServerDriver,
+    Headers, Method, Request, Responder, ServerDriver,
 };
 
+use super::{body::SinglePieceBody, types::H2RequestOrConnectionError};
+
 pub const MAX_WINDOW_SIZE: i64 = u32::MAX as i64;
 
 /// HTTP/2 server configuration
@@ -565,6 +567,8 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
     }
 
     async fn handle_event(&mut self, ev: H2Event) -> Result<(), H2ConnectionError> {
+        trace!(?ev, "handling event");
+
         match ev.payload {
             H2EventPayload::Headers(res) => {
                 let outgoing = match self
@@ -637,7 +641,7 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                 // FIXME: this isn't great, because, due to biased polling, body pieces can pile
                 // up. when we've collected enough pieces for max frame size, we
                 // should really send them.
-                outgoing.body.push_back(Piece::Full { core: chunk });
+                outgoing.body.push_back(chunk);
 
                 self.state.streams_with_pending_data.insert(ev.stream_id);
                 if self.state.outgoing_capacity > 0 && outgoing.capacity > 0 {
@@ -960,15 +964,53 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                     Some(StreamState::Transition) => unreachable!(),
                 }
 
-                self.read_headers(
-                    headers_or_trailers,
-                    mode,
-                    flags,
-                    frame.stream_id,
-                    payload,
-                    rx,
-                )
-                .await?;
+                if let Err(e) = self
+                    .read_headers(
+                        headers_or_trailers,
+                        mode,
+                        flags,
+                        frame.stream_id,
+                        payload,
+                        rx,
+                    )
+                    .await
+                {
+                    match e {
+                        H2RequestOrConnectionError::ConnectionError(e) => return Err(e),
+                        H2RequestOrConnectionError::RequestError(e) => {
+                            let stream_id = frame.stream_id;
+                            tracing::debug!(?e, %stream_id, "Responding to stream with error");
+                            // we need to insert it, otherwise `process_event` will ignore us
+                            // sending headers, etc.
+                            self.state.streams.insert(
+                                stream_id,
+                                StreamState::HalfClosedRemote {
+                                    outgoing: self.state.mk_stream_outgoing(),
+                                },
+                            );
+                            // TODO: inserting/removing here is probably unnecessary.
+
+                            // respond with status code
+                            let responder =
+                                Responder::new(H2Encoder::new(frame.stream_id, self.ev_tx.clone()));
+                            responder
+                                .write_final_response_with_body(
+                                    crate::Response {
+                                        version: Version::HTTP_2,
+                                        status: e.status,
+                                        headers: Default::default(),
+                                    },
+                                    &mut SinglePieceBody::new(e.message),
+                                )
+                                .await?;
+
+                            // don't even store the stream state anywhere, just record the last
+                            // stream id since we technically processed the request? maybe?
+                            // is returning a 4xx "processing" the request?
+                            self.state.last_stream_id = frame.stream_id;
+                        }
+                    }
+                }
             }
             FrameType::Priority => {
                 let pri_spec = match PrioritySpec::parse(payload) {
@@ -1269,7 +1311,7 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
         stream_id: StreamId,
         payload: Roll,
         rx: &mut mpsc::Receiver<(Frame, Roll)>,
-    ) -> Result<(), H2ConnectionError> {
+    ) -> Result<(), H2RequestOrConnectionError> {
         let end_stream = flags.contains(HeadersFlags::EndStream);
 
         enum Data {
@@ -1298,7 +1340,8 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                         return Err(H2ConnectionError::ExpectedContinuationFrame {
                             stream_id,
                             frame_type: None,
-                        });
+                        }
+                        .into());
                     }
                 };
 
@@ -1306,7 +1349,8 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                     return Err(H2ConnectionError::ExpectedContinuationForStream {
                         stream_id,
                         continuation_stream_id: continuation_frame.stream_id,
-                    });
+                    }
+                    .into());
                 }
 
                 let cont_flags = match continuation_frame.frame_type {
@@ -1315,7 +1359,8 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                         return Err(H2ConnectionError::ExpectedContinuationFrame {
                             stream_id,
                             frame_type: Some(other),
-                        })
+                        }
+                        .into())
                     }
                 };
 
@@ -1344,90 +1389,253 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
 
         let mut headers = Headers::default();
 
-        // TODO: find a way to propagate errors from here - probably will have to change
-        // the function signature in fluke-hpack, or just write to some captured
-        // error
-        let on_header_pair = |key: Cow<[u8]>, value: Cow<[u8]>| {
-            debug!(
-                "{headers_or_trailers:?} | {}: {}",
-                std::str::from_utf8(&key).unwrap_or("<non-utf8-key>"), /* TODO: does this hurt performance when debug logging is disabled? */
-                std::str::from_utf8(&value).unwrap_or("<non-utf8-value>"),
-            );
-
-            if &key[..1] == b":" {
-                if matches!(headers_or_trailers, HeadersOrTrailers::Trailers) {
-                    // TODO: proper error handling
-                    panic!("trailers cannot contain pseudo-headers");
+        {
+            // we assign to an outer variable because header decoding needs to finish no
+            // matter what: if we receive invalid headers for one request, we should still
+            // keep reading the next request's headers, and that requires advancing the
+            // huffman decoder's state, etc.
+            let mut req_error: Option<H2RequestError> = None;
+            let mut saw_regular_header = false;
+
+            let on_header_pair = |key: Cow<[u8]>, value: Cow<[u8]>| {
+                if req_error.is_some() {
+                    return;
                 }
 
-                // it's a pseudo-header!
-                // TODO: reject headers that occur after pseudo-headers
-                match &key[1..] {
-                    b"method" => {
-                        // TODO: error handling
-                        let value: PieceStr = Piece::from(value.to_vec()).to_str().unwrap();
-                        if method.replace(Method::from(value)).is_some() {
-                            unreachable!(); // No duplicate allowed.
-                        }
+                debug!(
+                    "{headers_or_trailers:?} | {}: {}",
+                    std::str::from_utf8(&key).unwrap_or("<non-utf8-key>"), /* TODO: does this
+                                                                            * hurt performance
+                                                                            * when debug logging
+                                                                            * is disabled? */
+                    std::str::from_utf8(&value).unwrap_or("<non-utf8-value>"),
+                );
+
+                if &key[..1] == b":" {
+                    if saw_regular_header {
+                        req_error = Some(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message:
+                                "bad request: All pseudo-header fields MUST appear in a field block before all regular field lines (RFC 9113, section 8.3)"
+                                    .into(),
+                        });
+                        return;
                     }
-                    b"scheme" => {
-                        // TODO: error handling
-                        let value: PieceStr = Piece::from(value.to_vec()).to_str().unwrap();
-                        if scheme.replace(value.parse().unwrap()).is_some() {
-                            unreachable!(); // No duplicate allowed.
-                        }
+
+                    if matches!(headers_or_trailers, HeadersOrTrailers::Trailers) {
+                        req_error = Some(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: Pseudo-header fields MUST NOT appear in a trailer section (RFC 9113, section 8.3)".into(),
+                        });
+                        return;
                     }
-                    b"path" => {
-                        // TODO: error handling
-                        let value: PieceStr = Piece::from(value.to_vec()).to_str().unwrap();
-                        if value.len() == 0 || path.replace(value).is_some() {
-                            unreachable!(); // No empty path nor duplicate
-                                            // allowed.
+
+                    // it's a pseudo-header!
+                    // TODO: reject headers that occur after pseudo-headers
+                    match &key[1..] {
+                        b"method" => {
+                            let value: PieceStr = match Piece::from(value.to_vec()).to_str() {
+                                Ok(p) => p,
+                                Err(_) => {
+                                    req_error = Some(H2RequestError {
+                                        status: StatusCode::BAD_REQUEST,
+                                        message: "bad request: invalid ':method' pseudo-header: not valid utf-8, so certainly not a valid method like POST, GET, OPTIONS, CONNECT, PROPFIND, etc.".into(),
+                                    });
+                                    return;
+                                }
+                            };
+                            if method.replace(Method::from(value)).is_some() {
+                                req_error = Some(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: duplicate ':method' pseudo-header. All HTTP/2 requests MUST include _exactly one_ valid value for the ':method', ':scheme', and ':path' pseudo-header fields, unless they are CONNECT requests (RFC 9114, section 8.3.1)".into(),
+                                });
+                            }
+                        }
+                        b"scheme" => {
+                            let value: PieceStr = match Piece::from(value.to_vec()).to_str() {
+                                Ok(p) => p,
+                                Err(_) => {
+                                    req_error = Some(H2RequestError {
+                                        status: StatusCode::BAD_REQUEST,
+                                        message: "bad request: invalid ':scheme' pseudo-header: not valid utf-8".into(),
+                                    });
+                                    return;
+                                }
+                            };
+                            if scheme.replace(value.parse().unwrap()).is_some() {
+                                req_error = Some(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: duplicate ':scheme' pseudo-header. All HTTP/2 requests MUST include _exactly one_ valid value for the ':method', ':scheme', and ':path' pseudo-header fields, unless they are CONNECT requests (RFC 9113, section 8.3.1)"
+                                        .into(),
+                                });
+                            }
+                        }
+                        b"path" => {
+                            let value: PieceStr = match Piece::from(value.to_vec()).to_str() {
+                                Ok(val) => val,
+                                Err(_) => {
+                                    req_error = Some(H2RequestError {
+                                        status: StatusCode::BAD_REQUEST,
+                                        message:
+                                            "bad request: invalid ':path' pseudo-header (not valid utf-8, which is _certainly_ not a valid URI, as defined by RFC 3986, section 2. See also RFC 9113, section 8.3.1). "
+                                                .into(),
+                                    });
+                                    return;
+                                }
+                            };
+
+                            if path.replace(value).is_some() {
+                                req_error = Some(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: duplicate ':path' pseudo-header. All HTTP/2 requests MUST include _exactly one_ valid value for the ':method', ':scheme', and ':path' pseudo-header fields, unless they are CONNECT requests (RFC 9113, section 8.3.1)".into(),
+                                });
+                            }
+                        }
+                        b"authority" => {
+                            let value: PieceStr = match Piece::from(value.to_vec()).to_str() {
+                                Ok(p) => p,
+                                Err(_) => {
+                                    req_error = Some(H2RequestError {
+                                        status: StatusCode::BAD_REQUEST,
+                                        message: "bad request: invalid ':authority' pseudo-header: not valid utf-8".into(),
+                                    });
+                                    return;
+                                }
+                            };
+                            let value: Authority = match value.parse() {
+                                Ok(a) => a,
+                                Err(_) => {
+                                    req_error = Some(H2RequestError {
+                                        status: StatusCode::BAD_REQUEST,
+                                        message: "bad request: invalid ':authority' pseudo-header: not a valid authority (which is to say: not a valid URI, see RFC 3986, section 3.2)".into(),
+                                    });
+                                    return;
+                                }
+                            };
+                            if authority.replace(value).is_some() {
+                                req_error = Some(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: duplicate ':authority' pseudo-header. This one isn't technically forbidden by RFC 9113 section 8, but... it feels like it should be."
+                                        .into(),
+                                });
+                            }
+                        }
+                        _ => {
+                            req_error = Some(H2RequestError {
+                                status: StatusCode::BAD_REQUEST,
+                                message:
+                                    "bad request: received invalid pseudo-header. the only defined pseudo-headers are: ':method', ':scheme', ':path', ':authority', ':status' (RFC 9113, section 8.1)"
+                                        .into(),
+                            });
                         }
                     }
-                    b"authority" => {
-                        // TODO: error handling
-                        let value: PieceStr = Piece::from(value.to_vec()).to_str().unwrap();
-                        if authority.replace(value.parse().unwrap()).is_some() {
-                            unreachable!(); // No duplicate allowed. (h2spec
-                                            // doesn't seem to test for
-                                            // this case but rejecting
-                                            // duplicates seems reasonable.)
+                } else {
+                    saw_regular_header = true;
+
+                    let name = match HeaderName::from_bytes(&key[..]) {
+                        Ok(name) => name,
+                        Err(_) => {
+                            req_error = Some(H2RequestError {
+                                status: StatusCode::BAD_REQUEST,
+                                message: "bad request: invalid header name. see RFC 9113, section 8.2.1, 'Field validity'".into(),
+                            });
+                            return;
                         }
+                    };
+
+                    // Note: An implementation that validates fields according to the definitions in
+                    // Sections 5.1 and 5.5 of [HTTP] only needs an additional check that field
+                    // names do not include uppercase characters.
+                    if key.iter().any(|b: &u8| b.is_ascii_uppercase()) {
+                        req_error = Some(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a, or 0x7f-0xff (all ranges inclusive). This specifically excludes all non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A' to 'Z', ASCII 0x41 to 0x5a). See RFC9113, section 8.2.1, 'Field Validity'".into(),
+                        });
+                        return;
                     }
-                    _ => {
-                        debug!("ignoring pseudo-header");
+
+                    // connection-specific headers are forbidden
+                    static KEEP_ALIVE: HeaderName = HeaderName::from_static("keep-alive");
+                    static PROXY_CONNECTION: HeaderName =
+                        HeaderName::from_static("proxy-connection");
+
+                    if name == http::header::CONNECTION
+                        || name == KEEP_ALIVE
+                        || name == PROXY_CONNECTION
+                        || name == http::header::TRANSFER_ENCODING
+                        || name == http::header::UPGRADE
+                    {
+                        req_error = Some(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: connection-specific headers are forbidden. see RFC 9113, section 8.1.2".into(),
+                        });
+                        return;
                     }
+
+                    if name == http::header::TE && &value[..] != b"trailers" {
+                        req_error = Some(H2RequestError {
+                                status: StatusCode::BAD_REQUEST,
+                                message: "bad request: 'te' did not contain 'trailers'. cf. RFC9113, Section 8.2.2: The only exception to this is the TE header field, which MAY be present in an HTTP/2 request; when it is, it MUST NOT contain any value other than 'trailers'".into(),
+                            });
+                        return;
+                    }
+
+                    // header values aren't allowed to have CR or LF
+                    let first = value.first();
+                    let last = value.last();
+                    if first == Some(&b' ')
+                        || first == Some(&b'\x09')
+                        || last == Some(&b' ')
+                        || last == Some(&b'\x09')
+                    {
+                        req_error = Some(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: A field value MUST NOT start or end with an ASCII whitespace character (ASCII SP or HTAB, 0x20 or 0x09). (RFC 9113, section 8.2.1, 'Field validity')".into(),
+                        });
+                        return;
+                    }
+
+                    if value
+                        .iter()
+                        .any(|&b| b == b'\r' || b == b'\n' || b == b'\0')
+                    {
+                        req_error = Some(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position. See RFC 9113, section 8.2.1, 'Field validity'".into(),
+                        });
+                        return;
+                    }
+
+                    let value: Piece = value.to_vec().into();
+                    headers.append(name, value);
                 }
-            } else {
-                // TODO: what do we do in case of malformed header names?
-                // ignore it? return a 400?
-                let name = HeaderName::from_bytes(&key[..]).expect("malformed header name");
-                let value: Piece = value.to_vec().into();
-                headers.append(name, value);
-            }
-        };
+            };
 
-        match data {
-            Data::Single(payload) => {
-                self.hpack_dec
-                    .decode_with_cb(&payload[..], on_header_pair)
-                    .map_err(|e| H2ConnectionError::CompressionError(format!("{e:?}")))?;
-            }
-            Data::Multi(fragments) => {
-                let total_len = fragments.iter().map(|f| f.len()).sum();
-                // this is a slow path, let's do a little heap allocation. we could
-                // be using `RollMut` for this, but it would probably need to resize
-                // a bunch
-                let mut payload = Vec::with_capacity(total_len);
-                for frag in &fragments {
-                    payload.extend_from_slice(&frag[..]);
+            match data {
+                Data::Single(payload) => {
+                    self.hpack_dec
+                        .decode_with_cb(&payload[..], on_header_pair)
+                        .map_err(|e| H2RequestOrConnectionError::ConnectionError(e.into()))?;
                 }
-                self.hpack_dec
-                    .decode_with_cb(&payload[..], on_header_pair)
-                    .map_err(|e| H2ConnectionError::CompressionError(format!("{e:?}")))?;
+                Data::Multi(fragments) => {
+                    let total_len = fragments.iter().map(|f| f.len()).sum();
+                    // this is a slow path, let's do a little heap allocation. we could
+                    // be using `RollMut` for this, but it would probably need to resize
+                    // a bunch
+                    let mut payload = Vec::with_capacity(total_len);
+                    for frag in &fragments {
+                        payload.extend_from_slice(&frag[..]);
+                    }
+                    self.hpack_dec
+                        .decode_with_cb(&payload[..], on_header_pair)
+                        .map_err(|e| H2RequestOrConnectionError::ConnectionError(e.into()))?;
+                }
+            };
+
+            if let Some(req_error) = req_error {
+                return Err(req_error.into());
             }
-        };
+        }
 
         match headers_or_trailers {
             HeadersOrTrailers::Headers => {
@@ -1436,18 +1644,137 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                 // field that identifies an entity that differs from the entity in the
                 // ":authority" pseudo-header field.
 
-                // TODO: proper error handling (return 400)
-                let method = method.unwrap();
-                let scheme = scheme.unwrap();
+                let method = match method {
+                    Some(method) => {
+                        if method == Method::Connect {
+                            // RFC 9113, section 8.5 'The CONNECT method': The ":scheme" and ":path"
+                            // pseudo-header fields MUST be omitted.
+                            if scheme.is_some() {
+                                return Err(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: CONNECT method MUST NOT include ':scheme' pseudo-header".into(),
+                                }
+                                .into());
+                            }
+                            if path.is_some() {
+                                return Err(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: CONNECT method MUST NOT include ':path' pseudo-header".into(),
+                                }
+                                .into());
+                            }
+                            if authority.is_none() {
+                                return Err(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: CONNECT method MUST include ':authority' pseudo-header".into(),
+                                }
+                                .into());
+                            }
 
-                let path = path.unwrap();
-                let path_and_query: PathAndQuery = path.parse().unwrap();
+                            // well, also, we just don't support the `CONNECT` method.
+                            return Err(H2RequestError {
+                                status: StatusCode::NOT_IMPLEMENTED,
+                                message: "bad request: CONNECT method is not supported".into(),
+                            }
+                            .into());
+                        }
+
+                        method
+                    }
+                    None => {
+                        return Err(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: missing :method pseudo-header".into(),
+                        }
+                        .into())
+                    }
+                };
+
+                let scheme = match scheme {
+                    Some(scheme) => scheme,
+                    None => {
+                        return Err(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: missing :scheme pseudo-header".into(),
+                        }
+                        .into())
+                    }
+                };
+
+                let path =
+                    match path {
+                        Some(path) => path,
+                        None => return Err(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message:
+                                "bad request: missing :path pseudo-header, cf. RFC9113, section 8.3.1: This pseudo-header field MUST NOT be empty for 'http' or 'https' URIs; 'http' or 'https' URIs that do not contain a path component MUST include a value of '/'."
+                                    .into(),
+                        }
+                        .into()),
+                    };
+
+                if path.len() == 0 && (scheme == Scheme::HTTP || scheme == Scheme::HTTPS) {
+                    return Err(H2RequestError {
+                        status: StatusCode::BAD_REQUEST,
+                        message: "bad request: as per RFC9113, section 8.3.1, ':path' header value MUST NOT be empty for 'http' and 'https' URIs".into(),
+                    }
+                    .into());
+                }
+
+                let path_and_query: PathAndQuery = match path.parse() {
+                    Ok(p) => p,
+                    Err(_) => {
+                        return Err(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message:
+                                "bad request: ':path' header value is not a valid PathAndQuery"
+                                    .into(),
+                        }
+                        .into())
+                    }
+                };
 
                 let authority = match authority {
-                    Some(authority) => Some(authority),
-                    None => headers
-                        .get(header::HOST)
-                        .map(|host| std::str::from_utf8(host).unwrap().parse().unwrap()),
+                    Some(authority) => {
+                        // if there's a `host` header, it must match the `:authority` pseudo-header
+                        if let Some(host) = headers.get(header::HOST) {
+                            let host = std::str::from_utf8(host).map_err(|_| H2RequestError {
+                                status: StatusCode::BAD_REQUEST,
+                                message: "bad request: 'host' header value is not utf-8".into(),
+                            })?;
+                            let host_authority: Authority =
+                                host.parse().map_err(|_| H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: 'host' header value is not a valid URI"
+                                        .into(),
+                                })?;
+                            if host_authority != authority {
+                                return Err(H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: 'host' header value does not match ':authority' pseudo-header value, cf. RFC9113, Section 8.3.1: A server SHOULD treat a request as malformed if it contains a Host header field that identifies an entity that differs from the entity in the ':authority' pseudo-header field".into(),
+                                }
+                                .into());
+                            }
+                        }
+
+                        Some(authority)
+                    }
+                    None => match headers.get(header::HOST) {
+                        Some(host) => {
+                            let host = std::str::from_utf8(host).map_err(|_| H2RequestError {
+                                status: StatusCode::BAD_REQUEST,
+                                message: "bad request: 'host' header value is not utf-8".into(),
+                            })?;
+                            let authority: Authority =
+                                host.parse().map_err(|_| H2RequestError {
+                                    status: StatusCode::BAD_REQUEST,
+                                    message: "bad request: 'host' header value is not a valid URI"
+                                        .into(),
+                                })?;
+                            Some(authority)
+                        }
+                        None => None,
+                    },
                 };
 
                 let mut uri_parts: http::uri::Parts = Default::default();
@@ -1455,7 +1782,16 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                 uri_parts.authority = authority;
                 uri_parts.path_and_query = Some(path_and_query);
 
-                let uri = http::uri::Uri::from_parts(uri_parts).unwrap();
+                let uri = match http::uri::Uri::from_parts(uri_parts) {
+                    Ok(uri) => uri,
+                    Err(_) => {
+                        return Err(H2RequestError {
+                            status: StatusCode::BAD_REQUEST,
+                            message: "bad request: invalid URI parts".into(),
+                        }
+                        .into())
+                    }
+                };
 
                 let req = Request {
                     method,
@@ -1464,17 +1800,7 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                     headers,
                 };
 
-                let responder = Responder {
-                    encoder: H2Encoder {
-                        stream_id,
-                        tx: self.ev_tx.clone(),
-                        state: EncoderState::ExpectResponseHeaders,
-                    },
-                    // TODO: why tf is this state encoded twice? is that really
-                    // necessary? I know it's for typestates and H2Encoder needs
-                    // to look up its state at runtime I guess, but.. that's not great?
-                    state: ExpectResponseHeaders,
-                };
+                let responder = Responder::new(H2Encoder::new(stream_id, self.ev_tx.clone()));
 
                 let (piece_tx, piece_rx) = mpsc::channel::<StreamIncomingItem>(1); // TODO: is 1 a sensible value here?
 
diff --git a/crates/fluke/src/h2/types.rs b/crates/fluke/src/h2/types.rs
index 786dd27f..23e2794d 100644
--- a/crates/fluke/src/h2/types.rs
+++ b/crates/fluke/src/h2/types.rs
@@ -3,7 +3,9 @@ use std::{
     fmt,
 };
 
-use fluke_buffet::{Piece, PieceCore};
+use fluke_buffet::Piece;
+use fluke_hpack::decoder::DecoderError;
+use http::StatusCode;
 use tokio::sync::Notify;
 
 use crate::Response;
@@ -274,6 +276,37 @@ impl BodyOutgoing {
     }
 }
 
+/// An error that may either indicate the peer is misbehaving
+/// or just a bad request from the client.
+#[derive(Debug, thiserror::Error)]
+pub(crate) enum H2RequestOrConnectionError {
+    #[error("connection error: {0}")]
+    ConnectionError(#[from] H2ConnectionError),
+
+    #[error("request error: {0}")]
+    RequestError(#[from] H2RequestError),
+}
+
+/// The client done goofed, we're returning 4xx most likely
+#[derive(thiserror::Error)]
+#[error("client error: {status:?}")]
+pub(crate) struct H2RequestError {
+    pub(crate) status: StatusCode,
+    pub(crate) message: Piece,
+}
+
+impl fmt::Debug for H2RequestError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let mut s = f.debug_struct("H2RequestError");
+        s.field("status", &self.status);
+        match std::str::from_utf8(&self.message[..]) {
+            Ok(body) => s.field("body", &body),
+            Err(_) => s.field("body", &"(not utf-8)"),
+        };
+        s.finish()
+    }
+}
+
 #[derive(Debug, thiserror::Error)]
 pub(crate) enum H2ConnectionError {
     #[error("frame too large: {frame_type:?} frame of size {frame_size} exceeds max frame size of {max_frame_size}")]
@@ -326,9 +359,8 @@ pub(crate) enum H2ConnectionError {
     #[error("on stream {stream_id}, received unexpected continuation frame")]
     UnexpectedContinuationFrame { stream_id: StreamId },
 
-    #[error("compression error: {0:?}")]
-    // FIXME: let's not use String, let's just replicate the enum from `fluke-hpack` or fix it?
-    CompressionError(String),
+    #[error("hpack decoding error: {0:?}")]
+    HpackDecodingError(#[from] DecoderError),
 
     #[error("client sent a push promise frame, clients aren't allowed to do that, cf. RFC9113 section 8.4")]
     ClientSentPushPromise,
@@ -407,7 +439,7 @@ impl H2ConnectionError {
                 ..
             }) => KnownErrorCode::FlowControlError,
             // compression errors
-            H2ConnectionError::CompressionError(_) => KnownErrorCode::CompressionError,
+            H2ConnectionError::HpackDecodingError(_) => KnownErrorCode::CompressionError,
             // stream closed error
             H2ConnectionError::StreamClosed { .. } => KnownErrorCode::StreamClosed,
             // internal errors
@@ -487,7 +519,7 @@ pub(crate) struct H2Event {
 
 pub(crate) enum H2EventPayload {
     Headers(Response),
-    BodyChunk(PieceCore),
+    BodyChunk(Piece),
     BodyEnd,
 }
 
diff --git a/crates/fluke/src/responder.rs b/crates/fluke/src/responder.rs
index 4b5bf99e..bef622d2 100644
--- a/crates/fluke/src/responder.rs
+++ b/crates/fluke/src/responder.rs
@@ -1,7 +1,7 @@
+use fluke_buffet::Piece;
 use http::header;
 
 use crate::{h1::body::BodyWriteMode, Body, BodyChunk, Headers, HeadersExt, Response};
-use fluke_buffet::PieceCore;
 
 pub trait ResponseState {}
 
@@ -21,14 +21,21 @@ where
     E: Encoder,
     S: ResponseState,
 {
-    pub(crate) encoder: E,
-    pub(crate) state: S,
+    encoder: E,
+    state: S,
 }
 
 impl<E> Responder<E, ExpectResponseHeaders>
 where
     E: Encoder,
 {
+    pub fn new(encoder: E) -> Self {
+        Self {
+            encoder,
+            state: ExpectResponseHeaders,
+        }
+    }
+
     /// Send an informational status code, cf. <https://httpwg.org/specs/rfc9110.html#status.1xx>
     /// Errors out if the response status is not 1xx
     pub async fn write_interim_response(&mut self, res: Response) -> eyre::Result<()> {
@@ -118,15 +125,16 @@ where
 {
     /// Send a response body chunk. Errors out if sending more than the
     /// announced content-length.
-    pub async fn write_chunk(&mut self, chunk: PieceCore) -> eyre::Result<()> {
+    pub async fn write_chunk(&mut self, chunk: Piece) -> eyre::Result<()> {
         self.encoder.write_body_chunk(chunk, self.state.mode).await
     }
 
     /// Finish the body, with optional trailers, cf. <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/TE>
     /// Errors out if the sent body doesn't match the announced content-length.
-    /// Errors out if trailers that weren't announced are being sent, or if the client
-    /// didn't explicitly announce it accepted trailers, or if the response is a 204,
-    /// 205 or 304, or if the body wasn't sent with chunked transfer encoding.
+    /// Errors out if trailers that weren't announced are being sent, or if the
+    /// client didn't explicitly announce it accepted trailers, or if the
+    /// response is a 204, 205 or 304, or if the body wasn't sent with
+    /// chunked transfer encoding.
     pub async fn finish_body(
         mut self,
         trailers: Option<Box<Headers>>,
@@ -158,8 +166,7 @@ where
 #[allow(async_fn_in_trait)] // we never require Send
 pub trait Encoder {
     async fn write_response(&mut self, res: Response) -> eyre::Result<()>;
-    async fn write_body_chunk(&mut self, chunk: PieceCore, mode: BodyWriteMode)
-        -> eyre::Result<()>;
+    async fn write_body_chunk(&mut self, chunk: Piece, mode: BodyWriteMode) -> eyre::Result<()>;
     async fn write_body_end(&mut self, mode: BodyWriteMode) -> eyre::Result<()>;
     async fn write_trailers(&mut self, trailers: Box<Headers>) -> eyre::Result<()>;
 }
diff --git a/crates/fluke/src/types/method.rs b/crates/fluke/src/types/method.rs
index e0c16a9b..cd5c1846 100644
--- a/crates/fluke/src/types/method.rs
+++ b/crates/fluke/src/types/method.rs
@@ -3,7 +3,7 @@ use std::fmt;
 use fluke_buffet::{Piece, PieceStr};
 
 /// An HTTP method, see <https://httpwg.org/specs/rfc9110.html#methods>
-#[derive(Clone)]
+#[derive(Clone, PartialEq, Eq)]
 pub enum Method {
     Get,
     Head,
diff --git a/crates/fluke/src/types/mod.rs b/crates/fluke/src/types/mod.rs
index fee9b56b..a834735a 100644
--- a/crates/fluke/src/types/mod.rs
+++ b/crates/fluke/src/types/mod.rs
@@ -3,7 +3,7 @@ use std::fmt::{self, Debug};
 use http::{StatusCode, Uri, Version};
 use tracing::debug;
 
-use fluke_buffet::PieceCore;
+use fluke_buffet::Piece;
 
 mod headers;
 pub use headers::*;
@@ -97,7 +97,7 @@ impl Response {
 
 /// A body chunk
 pub enum BodyChunk {
-    Chunk(PieceCore),
+    Chunk(Piece),
 
     /// The body finished, and it matched the announced content-length,
     /// or we were using a framed protocol
diff --git a/crates/httpwg-macros/src/lib.rs b/crates/httpwg-macros/src/lib.rs
index ac86a819..72e259b3 100644
--- a/crates/httpwg-macros/src/lib.rs
+++ b/crates/httpwg-macros/src/lib.rs
@@ -750,6 +750,401 @@ use __group::sends_rst_stream_frame_with_unknown_error_code as test;
 $body
 }
 }
+
+/// Section 8: Expressing HTTP Semantics in HTTP/2
+mod _8_expressing_http_semantics_in_http2 {
+use super::__suite::_8_expressing_http_semantics_in_http2 as __group;
+
+#[test]
+fn sends_second_headers_frame_without_end_stream() {
+use __group::sends_second_headers_frame_without_end_stream as test;
+$body
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_uppercase_field_name() {
+use __group::sends_headers_frame_with_uppercase_field_name as test;
+$body
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_space_in_field_name() {
+use __group::sends_headers_frame_with_space_in_field_name as test;
+$body
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_non_visible_ascii() {
+use __group::sends_headers_frame_with_non_visible_ascii as test;
+$body
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_del_character() {
+use __group::sends_headers_frame_with_del_character as test;
+$body
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_non_ascii_character() {
+use __group::sends_headers_frame_with_non_ascii_character as test;
+$body
+}
+
+/// With the exception of pseudo-header fields (Section 8.3), which have a name
+/// that starts with a single colon, field names MUST NOT include a colon (ASCII
+/// COLON, 0x3a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_colon_in_field_name() {
+use __group::sends_headers_frame_with_colon_in_field_name as test;
+$body
+}
+
+/// A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+/// (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_lf_in_field_value() {
+use __group::sends_headers_frame_with_lf_in_field_value as test;
+$body
+}
+
+/// A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+/// (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_cr_in_field_value() {
+use __group::sends_headers_frame_with_cr_in_field_value as test;
+$body
+}
+
+/// A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+/// (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_nul_in_field_value() {
+use __group::sends_headers_frame_with_nul_in_field_value as test;
+$body
+}
+
+/// A field value MUST NOT start or end with an ASCII whitespace character
+/// (ASCII SP or HTAB, 0x20 or 0x09).
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_leading_space_in_field_value() {
+use __group::sends_headers_frame_with_leading_space_in_field_value as test;
+$body
+}
+
+/// A field value MUST NOT start or end with an ASCII whitespace character
+/// (ASCII SP or HTAB, 0x20 or 0x09).
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+#[test]
+fn sends_headers_frame_with_trailing_tab_in_field_value() {
+use __group::sends_headers_frame_with_trailing_tab_in_field_value as test;
+$body
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_connection_header() {
+use __group::sends_headers_frame_with_connection_header as test;
+$body
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_proxy_connection_header() {
+use __group::sends_headers_frame_with_proxy_connection_header as test;
+$body
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_keep_alive_header() {
+use __group::sends_headers_frame_with_keep_alive_header as test;
+$body
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_transfer_encoding_header() {
+use __group::sends_headers_frame_with_transfer_encoding_header as test;
+$body
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_upgrade_header() {
+use __group::sends_headers_frame_with_upgrade_header as test;
+$body
+}
+
+/// The only exception to this is the TE header field, which MAY be present in
+/// an HTTP/2 request; when it is, it MUST NOT contain any value other than
+/// "trailers".
+#[test]
+fn sends_headers_frame_with_te_trailers() {
+use __group::sends_headers_frame_with_te_trailers as test;
+$body
+}
+
+/// The only exception to this is the TE header field, which MAY be present in
+/// an HTTP/2 request; when it is, it MUST NOT contain any value other than
+/// "trailers".
+#[test]
+fn sends_headers_frame_with_te_not_trailers() {
+use __group::sends_headers_frame_with_te_not_trailers as test;
+$body
+}
+
+/// [...] pseudo-header fields defined for responses MUST NOT appear in requests
+/// [...] Endpoints MUST treat a request or response that contains undefined or
+/// invalid pseudo-header fields as malformed (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_response_pseudo_header() {
+use __group::sends_headers_frame_with_response_pseudo_header as test;
+$body
+}
+
+/// [...] Pseudo-header fields MUST NOT appear in a trailer section. Endpoints
+/// MUST treat a request or response that contains undefined or invalid
+/// pseudo-header fields as malformed (Section 8.1.1).
+#[test]
+fn sends_headers_frame_with_pseudo_header_in_trailer() {
+use __group::sends_headers_frame_with_pseudo_header_in_trailer as test;
+$body
+}
+
+/// The same pseudo-header field name MUST NOT appear more than once in a field
+/// block. A field block for an HTTP request or response that contains a
+/// repeated pseudo-header field name MUST be treated as malformed (Section
+/// 8.1.1).
+#[test]
+fn sends_headers_frame_with_duplicate_pseudo_headers() {
+use __group::sends_headers_frame_with_duplicate_pseudo_headers as test;
+$body
+}
+
+/// A server SHOULD treat a request as malformed if it contains a Host header
+/// field that identifies an entity that differs from the entity in the
+/// ":authority" pseudo-header field. The values of fields need to be normalized
+/// to compare them (see Section 6.2 of [RFC3986]). An origin server can apply
+/// any normalization method, whereas other servers MUST perform scheme-based
+/// normalization (see Section 6.2.3 of [RFC3986]) of the two fields.
+///
+/// cf. <https://www.rfc-editor.org/rfc/rfc3986.html#section-6.2.3>
+#[test]
+fn sends_headers_frame_with_mismatched_host_authority() {
+use __group::sends_headers_frame_with_mismatched_host_authority as test;
+$body
+}
+
+/// This pseudo-header field MUST NOT be empty for "http" or "https" URIs;
+/// "http" or "https" URIs that do not contain a path component MUST include a
+/// value of '/'. The exceptions to this rule are:
+///
+/// an OPTIONS request for an "http" or "https" URI that does not include a path
+/// component; these MUST include a ":path" pseudo-header field with a value of
+/// '*' (see Section 7.1 of [HTTP]). CONNECT requests (Section 8.5), where the
+/// ":path" pseudo-header field is omitted.
+#[test]
+fn sends_headers_frame_with_empty_path_component() {
+use __group::sends_headers_frame_with_empty_path_component as test;
+$body
+}
+
+/// All HTTP/2 requests MUST include exactly one valid value for the ":method",
+/// ":scheme", and ":path" pseudo-header fields, unless they are CONNECT
+/// requests (Section 8.5). An HTTP request that omits mandatory pseudo-header
+/// fields is malformed (Section 8.1.1).
+#[test]
+fn sends_headers_frame_without_method() {
+use __group::sends_headers_frame_without_method as test;
+$body
+}
+
+#[test]
+fn sends_headers_frame_without_scheme() {
+use __group::sends_headers_frame_without_scheme as test;
+$body
+}
+
+#[test]
+fn sends_headers_frame_without_path() {
+use __group::sends_headers_frame_without_path as test;
+$body
+}
+
+#[test]
+fn sends_headers_frame_without_status() {
+use __group::sends_headers_frame_without_status as test;
+$body
+}
+
+/// A client cannot push. Thus, servers MUST treat the receipt of a PUSH_PROMISE
+/// frame as a connection error (Section 5.4.1) of type PROTOCOL_ERROR. A server
+/// cannot set the SETTINGS_ENABLE_PUSH setting to a value other than 0 (see
+/// Section 6.5.2).
+#[test]
+fn client_sends_push_promise_frame() {
+use __group::client_sends_push_promise_frame as test;
+$body
+}
+
+/// The CONNECT method (Section 9.3.6 of [HTTP]) is used to convert an HTTP
+/// connection into a tunnel to a remote host. CONNECT is primarily used with
+/// HTTP proxies to establish a TLS session with an origin server for the
+/// purposes of interacting with "https" resources.
+///
+/// In HTTP/2, the CONNECT method establishes a tunnel over a single HTTP/2
+/// stream to a remote host, rather than converting the entire connection to a
+/// tunnel. A CONNECT header section is constructed as defined in Section 8.3.1
+/// ("Request Pseudo-Header Fields"), with a few differences. Specifically:
+///
+/// The ":method" pseudo-header field is set to CONNECT.
+/// The ":scheme" and ":path" pseudo-header fields MUST be omitted.
+/// The ":authority" pseudo-header field contains the host and port to connect
+/// to (equivalent to the authority-form of the request-target of CONNECT
+/// requests; see Section 3.2.3 of [HTTP/1.1]).
+#[test]
+fn sends_connect_with_scheme() {
+use __group::sends_connect_with_scheme as test;
+$body
+}
+
+#[test]
+fn sends_connect_with_path() {
+use __group::sends_connect_with_path as test;
+$body
+}
+
+#[test]
+fn sends_connect_without_authority() {
+use __group::sends_connect_without_authority as test;
+$body
+}
+
+/// All pseudo-header fields MUST appear in a field block before all regular
+/// field lines (RFC 9113, section 8.3)
+#[test]
+fn sends_headers_frame_with_pseudo_headers_after_regular_headers() {
+use __group::sends_headers_frame_with_pseudo_headers_after_regular_headers as test;
+$body
+}
+}
 }
 }
 }
diff --git a/crates/httpwg/Cargo.toml b/crates/httpwg/Cargo.toml
index 1a7ac5b0..a1df6387 100644
--- a/crates/httpwg/Cargo.toml
+++ b/crates/httpwg/Cargo.toml
@@ -19,7 +19,6 @@ fluke-buffet = { version = "0.2.0", path = "../fluke-buffet" }
 fluke-h2-parse = { version = "0.1.1", path = "../fluke-h2-parse" }
 fluke-hpack = { version = "0.3.1", path = "../fluke-hpack" }
 futures-util = "0.3.30"
-multimap = { version = "0.10.0", default-features = false }
 pretty-hex = "0.4.1"
 tokio = { version = "1.37.0", features = ["time"] }
 tracing = "0.1.40"
diff --git a/crates/httpwg/src/lib.rs b/crates/httpwg/src/lib.rs
index 714529f3..48f625dc 100644
--- a/crates/httpwg/src/lib.rs
+++ b/crates/httpwg/src/lib.rs
@@ -1,7 +1,6 @@
 use eyre::eyre;
-use multimap::MultiMap;
 use rfc9113::DEFAULT_FRAME_SIZE;
-use std::{rc::Rc, time::Duration};
+use std::{collections::VecDeque, rc::Rc, time::Duration};
 
 use enumflags2::{bitflags, BitFlags};
 use fluke_buffet::{IntoHalves, Piece, PieceList, Roll, RollMut, WriteOwned};
@@ -19,9 +18,75 @@ use crate::rfc9113::default_settings;
 
 pub mod rfc9113;
 
-pub type Headers = MultiMap<Piece, Piece>;
+#[derive(Default)]
+pub struct Headers {
+    values: VecDeque<(Piece, Piece)>,
+}
+
+impl IntoIterator for Headers {
+    type Item = (Piece, Piece);
+    type IntoIter = std::collections::vec_deque::IntoIter<Self::Item>;
+
+    fn into_iter(self) -> Self::IntoIter {
+        self.values.into_iter()
+    }
+}
+
+impl Headers {
+    /// Appends a key-value pair to the end of the headers.
+    pub fn append(&mut self, key: impl Into<Piece>, value: impl Into<Piece>) {
+        self.values.push_back((key.into(), value.into()));
+    }
+
+    /// Prepends a key-value pair to the beginning of the headers.
+    /// (Useful when inserting pseudo-headers for example)
+    pub fn prepend(&mut self, key: impl Into<Piece>, value: impl Into<Piece>) {
+        self.values.push_front((key.into(), value.into()));
+    }
+
+    /// Replaces all values of the specified key with the provided value.
+    pub fn replace(&mut self, key: impl Into<Piece>, value: impl Into<Piece>) {
+        let key = key.into();
+        let value = value.into();
+        self.values.retain(|(k, _)| k != &key);
+        self.values.push_back((key, value));
+    }
+
+    /// Returns an iterator over the key-value pairs in the headers.
+    pub fn iter(&self) -> impl Iterator<Item = &(Piece, Piece)> {
+        self.values.iter()
+    }
+
+    /// Returns the number of key-value pairs in the headers.
+    pub fn len(&self) -> usize {
+        self.values.len()
+    }
+
+    /// Checks if the headers are empty.
+    pub fn is_empty(&self) -> bool {
+        self.values.is_empty()
+    }
+
+    /// Gets the first value associated with the specified key.
+    /// Returns `None` if the key is not present in the headers.
+    pub fn get_first(&self, key: &Piece) -> Option<&Piece> {
+        self.values
+            .iter()
+            .find_map(|(k, v)| if k == key { Some(v) } else { None })
+    }
+
+    /// Extends the headers with another set of headers.
+    pub fn extend(&mut self, other: Headers) {
+        self.values.extend(other);
+    }
+
+    /// Remove and return all key-value pairs matching the specified key
+    pub fn remove(&mut self, key: &Piece) {
+        self.values.retain(|(k, _)| k != key);
+    }
+}
 
-pub struct Conn<IO: IntoHalves + 'static> {
+pub struct Conn<IO: IntoHalves> {
     w: <IO as IntoHalves>::Write,
     scratch: RollMut,
     pub ev_rx: tokio::sync::mpsc::Receiver<Ev>,
@@ -638,10 +703,10 @@ impl<IO: IntoHalves> Conn<IO> {
         };
 
         let mut headers = Headers::default();
-        headers.insert(":method".into(), method.into());
-        headers.insert(":scheme".into(), scheme.into());
-        headers.insert(":path".into(), self.config.path.clone().into_bytes().into());
-        headers.insert(":authority".into(), authority.into_bytes().into());
+        headers.append(":method", method);
+        headers.append(":scheme", scheme);
+        headers.append(":path", self.config.path.clone().into_bytes());
+        headers.append(":authority", authority.into_bytes());
         headers
     }
 
@@ -666,8 +731,18 @@ impl<IO: IntoHalves> Conn<IO> {
             .decode(&fragment[..])
             .map_err(|e| eyre::eyre!("hpack decoder error: {e:?}"))?;
         for (k, v) in res {
-            headers.insert(k.into(), v.into());
+            headers.append(k, v);
+        }
+
+        tracing::trace!("decoded {} headers:", headers.len());
+        for (k, v) in headers.iter() {
+            tracing::trace!(
+                "    {:?} => {:?}",
+                String::from_utf8_lossy(&k[..]),
+                String::from_utf8_lossy(&v[..])
+            );
         }
+
         Ok(headers)
     }
 
@@ -782,7 +857,7 @@ impl<IO: IntoHalves> Conn<IO> {
 
         for i in 0..len {
             let name = format!("x-dummy{}", i);
-            headers.insert(name.into_bytes().into(), dummy.clone().into());
+            headers.append(name.into_bytes(), dummy.clone());
         }
 
         headers
@@ -823,6 +898,38 @@ impl<IO: IntoHalves> Conn<IO> {
         assert!(frame.is_ack());
         Ok(())
     }
+
+    async fn send_req_and_expect_status(
+        &mut self,
+        stream_id: StreamId,
+        headers: &Headers,
+        expected_status: u16,
+    ) -> eyre::Result<()> {
+        self.encode_and_write_headers(
+            stream_id,
+            HeadersFlags::EndHeaders | HeadersFlags::EndStream,
+            headers,
+        )
+        .await?;
+
+        let (frame, payload) = self.wait_for_frame(FrameT::Headers).await.unwrap();
+        assert!(
+            frame.is_end_headers(),
+            "the server is free to answer with headers in several frames but this breaks that test"
+        );
+
+        let headers = self.decode_headers(payload.into())?;
+        let status = headers
+            .get_first(&":status".into())
+            .expect("response should contain :status");
+        let status = std::str::from_utf8(&status[..])
+            .expect("status should be valid utf-8")
+            .parse::<u16>()
+            .expect("status should be a u16");
+        assert_eq!(status, expected_status);
+
+        Ok(())
+    }
 }
 
 /// Parameters for tests
@@ -856,7 +963,7 @@ impl Default for Config {
 
             max_header_len: 4000,
 
-            timeout: Duration::from_secs(1),
+            timeout: Duration::from_millis(100),
         }
     }
 }
diff --git a/crates/httpwg/src/rfc9113/_3_starting_http2.rs b/crates/httpwg/src/rfc9113/_3_starting_http2.rs
index eb94fe3f..7a2713f3 100644
--- a/crates/httpwg/src/rfc9113/_3_starting_http2.rs
+++ b/crates/httpwg/src/rfc9113/_3_starting_http2.rs
@@ -10,7 +10,7 @@ use crate::{rfc9113::default_settings, Conn, ErrorC, FrameT};
 /// The server connection preface consists of a potentially empty
 /// SETTINGS frame (Section 6.5) that MUST be the first frame
 /// the server sends in the HTTP/2 connection.
-pub async fn sends_client_connection_preface<IO: IntoHalves + 'static>(
+pub async fn sends_client_connection_preface<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.send(PREFACE).await?;
@@ -26,7 +26,7 @@ pub async fn sends_client_connection_preface<IO: IntoHalves + 'static>(
 
 /// Clients and servers MUST treat an invalid connection preface as
 /// a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_invalid_connection_preface<IO: IntoHalves + 'static>(
+pub async fn sends_invalid_connection_preface<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.send("INVALID CONNECTION PREFACE\r\n\r\n").await?;
diff --git a/crates/httpwg/src/rfc9113/_4_http_frames.rs b/crates/httpwg/src/rfc9113/_4_http_frames.rs
index 73c73b53..78313d05 100644
--- a/crates/httpwg/src/rfc9113/_4_http_frames.rs
+++ b/crates/httpwg/src/rfc9113/_4_http_frames.rs
@@ -10,9 +10,7 @@ use fluke_h2_parse::{
 //---- Section 4.1: Frame Format
 
 /// Implementations MUST ignore and discard frames of unknown types.
-pub async fn sends_frame_with_unknown_type<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn sends_frame_with_unknown_type<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
 
     conn.write_frame(
@@ -32,9 +30,7 @@ pub async fn sends_frame_with_unknown_type<IO: IntoHalves + 'static>(
 
 /// Unused flags MUST be ignored on receipt and MUST be left
 /// unset (0x00) when sending.
-pub async fn sends_frame_with_unused_flags<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn sends_frame_with_unused_flags<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
 
     let data = b"sfunflgs";
@@ -55,7 +51,7 @@ pub async fn sends_frame_with_unused_flags<IO: IntoHalves + 'static>(
 /// Reserved: A reserved 1-bit field. The semantics of this bit are
 /// undefined, and the bit MUST remain unset (0x00) when sending and
 /// MUST be ignored when receiving.
-pub async fn sends_frame_with_reserved_bit_set<IO: IntoHalves + 'static>(
+pub async fn sends_frame_with_reserved_bit_set<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -80,9 +76,7 @@ pub async fn sends_frame_with_reserved_bit_set<IO: IntoHalves + 'static>(
 // All implementations MUST be capable of receiving and minimally
 // processing frames up to 2^14 octets in length, plus the 9-octet
 // frame header (Section 4.1).
-pub async fn data_frame_with_max_length<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn data_frame_with_max_length<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     let stream_id = StreamId(1);
     conn.handshake().await?;
 
@@ -102,9 +96,7 @@ pub async fn data_frame_with_max_length<IO: IntoHalves + 'static>(
 /// exceeds the size defined in SETTINGS_MAX_FRAME_SIZE, exceeds any
 /// limit defined for the frame type, or is too small to contain mandatory frame
 /// data
-pub async fn frame_exceeding_max_size<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn frame_exceeding_max_size<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     let stream_id = StreamId(1);
     conn.handshake().await?;
 
@@ -131,7 +123,7 @@ pub async fn frame_exceeding_max_size<IO: IntoHalves + 'static>(
 /// (Section 5.4.1); this includes any frame carrying a field block
 /// (Section 4.3) (that is, HEADERS, PUSH_PROMISE, and CONTINUATION),
 /// a SETTINGS frame, and any frame with a stream identifier of 0.
-pub async fn large_headers_frame_exceeding_max_size<IO: IntoHalves + 'static>(
+pub async fn large_headers_frame_exceeding_max_size<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -160,9 +152,7 @@ pub async fn large_headers_frame_exceeding_max_size<IO: IntoHalves + 'static>(
 
 /// A decoding error in a header block MUST be treated as a connection error
 /// (Section 5.4.1) of type COMPRESSION_ERROR.
-pub async fn invalid_header_block_fragment<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn invalid_header_block_fragment<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
 
     // Literal Header Field with Incremental Indexing without
@@ -183,7 +173,7 @@ pub async fn invalid_header_block_fragment<IO: IntoHalves + 'static>(
 /// Each header block is processed as a discrete unit. Header blocks
 /// MUST be transmitted as a contiguous sequence of frames, with no
 /// interleaved frames of any other type or from any other stream.
-pub async fn priority_frame_while_sending_headers<IO: IntoHalves + 'static>(
+pub async fn priority_frame_while_sending_headers<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -223,7 +213,7 @@ pub async fn priority_frame_while_sending_headers<IO: IntoHalves + 'static>(
 /// Each header block is processed as a discrete unit. Header blocks
 /// MUST be transmitted as a contiguous sequence of frames, with no
 /// interleaved frames of any other type or from any other stream.
-pub async fn headers_frame_to_another_stream<IO: IntoHalves + 'static>(
+pub async fn headers_frame_to_another_stream<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
diff --git a/crates/httpwg/src/rfc9113/_5_streams_and_multiplexing.rs b/crates/httpwg/src/rfc9113/_5_streams_and_multiplexing.rs
index 5cbdf5fc..16b49533 100644
--- a/crates/httpwg/src/rfc9113/_5_streams_and_multiplexing.rs
+++ b/crates/httpwg/src/rfc9113/_5_streams_and_multiplexing.rs
@@ -14,9 +14,7 @@ use crate::{dummy_bytes, Conn, ErrorC};
 /// Receiving any frame other than HEADERS or PRIORITY on a stream
 /// in this state MUST be treated as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn idle_sends_data_frame<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn idle_sends_data_frame<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
     conn.write_data(StreamId(1), true, b"test").await?;
 
@@ -34,9 +32,7 @@ pub async fn idle_sends_data_frame<IO: IntoHalves + 'static>(
 /// Receiving any frame other than HEADERS or PRIORITY on a stream
 /// in this state MUST be treated as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn idle_sends_rst_stream_frame<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn idle_sends_rst_stream_frame<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
     conn.write_rst_stream(StreamId(1), ErrorC::Cancel).await?;
     conn.verify_connection_error(ErrorC::ProtocolError).await?;
@@ -47,7 +43,7 @@ pub async fn idle_sends_rst_stream_frame<IO: IntoHalves + 'static>(
 /// Receiving any frame other than HEADERS or PRIORITY on a stream
 /// in this state MUST be treated as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn idle_sends_window_update_frame<IO: IntoHalves + 'static>(
+pub async fn idle_sends_window_update_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -60,9 +56,7 @@ pub async fn idle_sends_window_update_frame<IO: IntoHalves + 'static>(
 /// Receiving any frame other than HEADERS or PRIORITY on a stream
 /// in this state MUST be treated as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn idle_sends_continuation_frame<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn idle_sends_continuation_frame<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
     let block_fragment = conn.encode_headers(&conn.common_headers("POST"))?;
     conn.write_continuation(StreamId(1), ContinuationFlags::EndHeaders, block_fragment)
@@ -76,7 +70,7 @@ pub async fn idle_sends_continuation_frame<IO: IntoHalves + 'static>(
 /// WINDOW_UPDATE, PRIORITY, or RST_STREAM, for a stream that is in
 /// this state, it MUST respond with a stream error (Section 5.4.2)
 /// of type STREAM_CLOSED.
-pub async fn half_closed_remote_sends_data_frame<IO: IntoHalves + 'static>(
+pub async fn half_closed_remote_sends_data_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -92,7 +86,7 @@ pub async fn half_closed_remote_sends_data_frame<IO: IntoHalves + 'static>(
 /// WINDOW_UPDATE, PRIORITY, or RST_STREAM, for a stream that is in
 /// this state, it MUST respond with a stream error (Section 5.4.2)
 /// of type STREAM_CLOSED.
-pub async fn half_closed_remote_sends_headers_frame<IO: IntoHalves + 'static>(
+pub async fn half_closed_remote_sends_headers_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -108,7 +102,7 @@ pub async fn half_closed_remote_sends_headers_frame<IO: IntoHalves + 'static>(
 /// WINDOW_UPDATE, PRIORITY, or RST_STREAM, for a stream that is in
 /// this state, it MUST respond with a stream error (Section 5.4.2)
 /// of type STREAM_CLOSED.
-pub async fn half_closed_remote_sends_continuation_frame<IO: IntoHalves + 'static>(
+pub async fn half_closed_remote_sends_continuation_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -135,7 +129,7 @@ pub async fn half_closed_remote_sends_continuation_frame<IO: IntoHalves + 'stati
 /// An endpoint that receives any frame other than PRIORITY after
 /// receiving a RST_STREAM MUST treat that as a stream error
 /// (Section 5.4.2) of type STREAM_CLOSED.
-pub async fn closed_sends_data_frame_after_rst_stream<IO: IntoHalves + 'static>(
+pub async fn closed_sends_data_frame_after_rst_stream<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -156,7 +150,7 @@ pub async fn closed_sends_data_frame_after_rst_stream<IO: IntoHalves + 'static>(
 /// An endpoint that receives any frame other than PRIORITY after
 /// receiving a RST_STREAM MUST treat that as a stream error
 /// (Section 5.4.2) of type STREAM_CLOSED.
-pub async fn closed_sends_headers_frame_after_rst_stream<IO: IntoHalves + 'static>(
+pub async fn closed_sends_headers_frame_after_rst_stream<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -177,7 +171,7 @@ pub async fn closed_sends_headers_frame_after_rst_stream<IO: IntoHalves + 'stati
 /// An endpoint that receives any frame other than PRIORITY after
 /// receiving a RST_STREAM MUST treat that as a stream error
 /// (Section 5.4.2) of type STREAM_CLOSED.
-pub async fn closed_sends_continuation_frame_after_rst_stream<IO: IntoHalves + 'static>(
+pub async fn closed_sends_continuation_frame_after_rst_stream<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -213,9 +207,7 @@ pub async fn closed_sends_continuation_frame_after_rst_stream<IO: IntoHalves + '
 /// An endpoint that receives any frames after receiving a frame
 /// with the END_STREAM flag set MUST treat that as a connection
 /// error (Section 6.4.1) of type STREAM_CLOSED.
-pub async fn closed_sends_data_frame<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn closed_sends_data_frame<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     let stream_id = StreamId(1);
     conn.handshake().await?;
 
@@ -231,9 +223,7 @@ pub async fn closed_sends_data_frame<IO: IntoHalves + 'static>(
 /// An endpoint that receives any frames after receiving a frame
 /// with the END_STREAM flag set MUST treat that as a connection
 /// error (Section 6.4.1) of type STREAM_CLOSED.
-pub async fn closed_sends_headers_frame<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn closed_sends_headers_frame<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     let stream_id = StreamId(1);
     conn.handshake().await?;
 
@@ -249,7 +239,7 @@ pub async fn closed_sends_headers_frame<IO: IntoHalves + 'static>(
 /// An endpoint that receives any frames after receiving a frame
 /// with the END_STREAM flag set MUST treat that as a connection
 /// error (Section 6.4.1) of type STREAM_CLOSED.
-pub async fn closed_sends_continuation_frame<IO: IntoHalves + 'static>(
+pub async fn closed_sends_continuation_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -278,7 +268,7 @@ pub async fn closed_sends_continuation_frame<IO: IntoHalves + 'static>(
 /// An endpoint that receives an unexpected stream identifier
 /// MUST respond with a connection error (Section 5.4.1) of
 /// type PROTOCOL_ERROR.
-pub async fn sends_even_numbered_stream_identifier<IO: IntoHalves + 'static>(
+pub async fn sends_even_numbered_stream_identifier<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -292,7 +282,7 @@ pub async fn sends_even_numbered_stream_identifier<IO: IntoHalves + 'static>(
 /// An endpoint that receives an unexpected stream identifier
 /// MUST respond with a connection error (Section 5.4.1) of
 /// type PROTOCOL_ERROR.
-pub async fn sends_smaller_stream_identifier<IO: IntoHalves + 'static>(
+pub async fn sends_smaller_stream_identifier<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -306,7 +296,7 @@ pub async fn sends_smaller_stream_identifier<IO: IntoHalves + 'static>(
 
 //---- Section 5.1.2: Stream Concurrency
 
-pub async fn exceeds_concurrent_stream_limit<IO: IntoHalves + 'static>(
+pub async fn exceeds_concurrent_stream_limit<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -339,7 +329,7 @@ pub async fn exceeds_concurrent_stream_limit<IO: IntoHalves + 'static>(
 
 /// After sending the GOAWAY frame for an error condition,
 /// the endpoint MUST close the TCP connection.
-pub async fn invalid_ping_frame_for_connection_close<IO: IntoHalves + 'static>(
+pub async fn invalid_ping_frame_for_connection_close<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -359,7 +349,7 @@ pub async fn invalid_ping_frame_for_connection_close<IO: IntoHalves + 'static>(
 // An endpoint that encounters a connection error SHOULD first send
 // a GOAWAY frame (Section 6.8) with the stream identifier of the last
 // stream that it successfully received from its peer.
-pub async fn test_invalid_ping_frame_for_goaway<IO: IntoHalves + 'static>(
+pub async fn test_invalid_ping_frame_for_goaway<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -380,7 +370,7 @@ pub async fn test_invalid_ping_frame_for_goaway<IO: IntoHalves + 'static>(
 /// Extension frames that appear in the middle of a header block
 /// (Section 4.3) are not permitted; these MUST be treated as
 /// a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn unknown_extension_frame_in_header_block<IO: IntoHalves + 'static>(
+pub async fn unknown_extension_frame_in_header_block<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
diff --git a/crates/httpwg/src/rfc9113/_6_frame_definitions.rs b/crates/httpwg/src/rfc9113/_6_frame_definitions.rs
index 73bdf2fc..881f7037 100644
--- a/crates/httpwg/src/rfc9113/_6_frame_definitions.rs
+++ b/crates/httpwg/src/rfc9113/_6_frame_definitions.rs
@@ -15,7 +15,7 @@ use crate::{dummy_bytes, Conn, ErrorC, FrameT};
 /// received whose stream identifier field is 0x0, the recipient
 /// MUST respond with a connection error (Section 5.4.1) of type
 /// PROTOCOL_ERROR.
-pub async fn sends_data_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_data_frame_with_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -32,7 +32,7 @@ pub async fn sends_data_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
 /// a stream error (Section 5.4.2) of type STREAM_CLOSED.
 ///
 /// Note: This test case is duplicated with 5.1.
-pub async fn sends_data_frame_on_invalid_stream_state<IO: IntoHalves + 'static>(
+pub async fn sends_data_frame_on_invalid_stream_state<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -57,7 +57,7 @@ pub async fn sends_data_frame_on_invalid_stream_state<IO: IntoHalves + 'static>(
 /// If the length of the padding is the length of the frame payload
 /// or greater, the recipient MUST treat this as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_data_frame_with_invalid_pad_length<IO: IntoHalves + 'static>(
+pub async fn sends_data_frame_with_invalid_pad_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -65,7 +65,7 @@ pub async fn sends_data_frame_with_invalid_pad_length<IO: IntoHalves + 'static>(
     conn.handshake().await?;
 
     let mut headers = conn.common_headers("POST");
-    headers.insert("content-length".into(), "4".into());
+    headers.append("content-length", "4");
     let block_fragment = conn.encode_headers(&headers)?;
 
     conn.write_headers(stream_id, HeadersFlags::EndHeaders, block_fragment)
@@ -87,7 +87,7 @@ pub async fn sends_data_frame_with_invalid_pad_length<IO: IntoHalves + 'static>(
 /// frame is received whose stream identifier field is 0x0, the
 /// recipient MUST respond with a connection error (Section 5.4.1)
 /// of type PROTOCOL_ERROR.
-pub async fn sends_headers_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_headers_frame_with_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -110,7 +110,7 @@ pub async fn sends_headers_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
 /// are identical to those defined for DATA frames (Section 6.1).
 /// Padding that exceeds the size remaining for the header block
 /// fragment MUST be treated as a PROTOCOL_ERROR.
-pub async fn sends_headers_frame_with_invalid_pad_length<IO: IntoHalves + 'static>(
+pub async fn sends_headers_frame_with_invalid_pad_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -141,7 +141,7 @@ pub async fn sends_headers_frame_with_invalid_pad_length<IO: IntoHalves + 'stati
 /// frame is received with a stream identifier of 0x0, the recipient
 /// MUST respond with a connection error (Section 5.4.1) of type
 /// PROTOCOL_ERROR.
-pub async fn sends_priority_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_priority_frame_with_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -164,7 +164,7 @@ pub async fn sends_priority_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
 /// A PRIORITY frame with a length other than 5 octets MUST be
 /// treated as a stream error (Section 5.4.2) of type
 /// FRAME_SIZE_ERROR.
-pub async fn sends_priority_frame_with_invalid_length<IO: IntoHalves + 'static>(
+pub async fn sends_priority_frame_with_invalid_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -197,7 +197,7 @@ pub async fn sends_priority_frame_with_invalid_length<IO: IntoHalves + 'static>(
 /// RST_STREAM frame is received with a stream identifier of 0x0,
 /// the recipient MUST treat this as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_rst_stream_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_rst_stream_frame_with_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -214,7 +214,7 @@ pub async fn sends_rst_stream_frame_with_zero_stream_id<IO: IntoHalves + 'static
 /// state. If a RST_STREAM frame identifying an idle stream is
 /// received, the recipient MUST treat this as a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_rst_stream_frame_on_idle_stream<IO: IntoHalves + 'static>(
+pub async fn sends_rst_stream_frame_on_idle_stream<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -229,7 +229,7 @@ pub async fn sends_rst_stream_frame_on_idle_stream<IO: IntoHalves + 'static>(
 /// A RST_STREAM frame with a length other than 4 octets MUST be
 /// treated as a connection error (Section 5.4.1) of type
 /// FRAME_SIZE_ERROR.
-pub async fn sends_rst_stream_frame_with_invalid_length<IO: IntoHalves + 'static>(
+pub async fn sends_rst_stream_frame_with_invalid_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -264,7 +264,7 @@ pub async fn sends_rst_stream_frame_with_invalid_length<IO: IntoHalves + 'static
 /// a SETTINGS frame with the ACK flag set and a length field value
 /// other than 0 MUST be treated as a connection error (Section 5.4.1)
 /// of type FRAME_SIZE_ERROR.
-pub async fn sends_settings_frame_with_ack_and_payload<IO: IntoHalves + 'static>(
+pub async fn sends_settings_frame_with_ack_and_payload<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -289,7 +289,7 @@ pub async fn sends_settings_frame_with_ack_and_payload<IO: IntoHalves + 'static>
 /// stream identifier field is anything other than 0x0, the
 /// endpoint MUST respond with a connection error (Section 5.4.1)
 /// of type PROTOCOL_ERROR.
-pub async fn sends_settings_frame_with_non_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_settings_frame_with_non_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -312,7 +312,7 @@ pub async fn sends_settings_frame_with_non_zero_stream_id<IO: IntoHalves + 'stat
 /// A SETTINGS frame with a length other than a multiple of 6 octets
 /// MUST be treated as a connection error (Section 5.4.1) of type
 /// FRAME_SIZE_ERROR.
-pub async fn sends_settings_frame_with_invalid_length<IO: IntoHalves + 'static>(
+pub async fn sends_settings_frame_with_invalid_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -337,7 +337,7 @@ pub async fn sends_settings_frame_with_invalid_length<IO: IntoHalves + 'static>(
 /// The initial value is 1, which indicates that server push is
 /// permitted. Any value other than 0 or 1 MUST be treated as a
 /// connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_settings_enable_push_with_invalid_value<IO: IntoHalves + 'static>(
+pub async fn sends_settings_enable_push_with_invalid_value<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -353,7 +353,7 @@ pub async fn sends_settings_enable_push_with_invalid_value<IO: IntoHalves + 'sta
 /// Values above the maximum flow-control window size of 2^31-1
 /// MUST be treated as a connection error (Section 5.4.1) of
 /// type FLOW_CONTROL_ERROR.
-pub async fn sends_settings_initial_window_size_with_invalid_value<IO: IntoHalves + 'static>(
+pub async fn sends_settings_initial_window_size_with_invalid_value<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -373,9 +373,7 @@ pub async fn sends_settings_initial_window_size_with_invalid_value<IO: IntoHalve
 /// maximum allowed frame size (2^24-1 or 16,777,215 octets),
 /// inclusive. Values outside this range MUST be treated as a
 /// connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_settings_max_frame_size_with_invalid_value_below_initial<
-    IO: IntoHalves + 'static,
->(
+pub async fn sends_settings_max_frame_size_with_invalid_value_below_initial<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -394,9 +392,7 @@ pub async fn sends_settings_max_frame_size_with_invalid_value_below_initial<
 /// maximum allowed frame size (2^24-1 or 16,777,215 octets),
 /// inclusive. Values outside this range MUST be treated as a
 /// connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_settings_max_frame_size_with_invalid_value_above_max<
-    IO: IntoHalves + 'static,
->(
+pub async fn sends_settings_max_frame_size_with_invalid_value_above_max<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -411,7 +407,7 @@ pub async fn sends_settings_max_frame_size_with_invalid_value_above_max<
 
 /// An endpoint that receives a SETTINGS frame with any unknown
 /// or unsupported identifier MUST ignore that setting.
-pub async fn sends_settings_frame_with_unknown_identifier<IO: IntoHalves + 'static>(
+pub async fn sends_settings_frame_with_unknown_identifier<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -436,7 +432,7 @@ pub async fn sends_settings_frame_with_unknown_identifier<IO: IntoHalves + 'stat
 
 /// The values in the SETTINGS frame MUST be processed in the order
 /// they appear, with no other frame processing between values.
-pub async fn sends_multiple_values_of_settings_initial_window_size<IO: IntoHalves + 'static>(
+pub async fn sends_multiple_values_of_settings_initial_window_size<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -473,7 +469,7 @@ pub async fn sends_multiple_values_of_settings_initial_window_size<IO: IntoHalve
 
 /// Once all values have been processed, the recipient MUST
 /// immediately emit a SETTINGS frame with the ACK flag set.
-pub async fn sends_settings_frame_without_ack_flag<IO: IntoHalves + 'static>(
+pub async fn sends_settings_frame_without_ack_flag<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -499,7 +495,7 @@ pub async fn sends_settings_frame_without_ack_flag<IO: IntoHalves + 'static>(
 /// Receivers of a PING frame that does not include an ACK flag MUST
 /// send a PING frame with the ACK flag set in response, with an
 /// identical payload.
-pub async fn sends_ping_frame<IO: IntoHalves + 'static>(mut conn: Conn<IO>) -> eyre::Result<()> {
+pub async fn sends_ping_frame<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
 
     let data = b"h2spec\0\0";
@@ -515,9 +511,7 @@ pub async fn sends_ping_frame<IO: IntoHalves + 'static>(mut conn: Conn<IO>) -> e
 /// response. An endpoint MUST set this flag in PING responses.
 /// An endpoint MUST NOT respond to PING frames containing this
 /// flag.
-pub async fn sends_ping_frame_with_ack<IO: IntoHalves + 'static>(
-    mut conn: Conn<IO>,
-) -> eyre::Result<()> {
+pub async fn sends_ping_frame_with_ack<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
     conn.handshake().await?;
 
     let unexpected_data = b"invalid\0";
@@ -533,7 +527,7 @@ pub async fn sends_ping_frame_with_ack<IO: IntoHalves + 'static>(
 /// If a PING frame is received with a stream identifier field value
 /// other than 0x0, the recipient MUST respond with a connection
 /// error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_ping_frame_with_non_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_ping_frame_with_non_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -552,7 +546,7 @@ pub async fn sends_ping_frame_with_non_zero_stream_id<IO: IntoHalves + 'static>(
 /// Receipt of a PING frame with a length field value other than 8
 /// MUST be treated as a connection error (Section 5.4.1) of type
 /// FRAME_SIZE_ERROR.
-pub async fn sends_ping_frame_with_invalid_length<IO: IntoHalves + 'static>(
+pub async fn sends_ping_frame_with_invalid_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -573,7 +567,7 @@ pub async fn sends_ping_frame_with_invalid_length<IO: IntoHalves + 'static>(
 /// An endpoint MUST treat a GOAWAY frame with a stream identifier
 /// other than 0x0 as a connection error (Section 5.4.1) of type
 /// PROTOCOL_ERROR.
-pub async fn sends_goaway_frame_with_non_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_goaway_frame_with_non_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -600,7 +594,7 @@ pub async fn sends_goaway_frame_with_non_zero_stream_id<IO: IntoHalves + 'static
 /// (Section 5.4.2) of type PROTOCOL_ERROR; errors on the connection
 /// flow-control window MUST be treated as a connection error
 /// (Section 5.4.1).
-pub async fn sends_window_update_frame_with_zero_increment<IO: IntoHalves + 'static>(
+pub async fn sends_window_update_frame_with_zero_increment<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -617,7 +611,7 @@ pub async fn sends_window_update_frame_with_zero_increment<IO: IntoHalves + 'sta
 /// (Section 5.4.2) of type PROTOCOL_ERROR; errors on the connection
 /// flow-control window MUST be treated as a connection error
 /// (Section 5.4.1).
-pub async fn sends_window_update_frame_with_zero_increment_on_stream<IO: IntoHalves + 'static>(
+pub async fn sends_window_update_frame_with_zero_increment_on_stream<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -625,7 +619,7 @@ pub async fn sends_window_update_frame_with_zero_increment_on_stream<IO: IntoHal
     conn.handshake().await?;
 
     let mut headers = conn.common_headers("POST");
-    headers.insert(":method".into(), "POST".into());
+    headers.append(":method", "POST");
 
     let block_fragment = conn.encode_headers(&headers)?;
 
@@ -642,7 +636,7 @@ pub async fn sends_window_update_frame_with_zero_increment_on_stream<IO: IntoHal
 /// A WINDOW_UPDATE frame with a length other than 4 octets MUST
 /// be treated as a connection error (Section 5.4.1) of type
 /// FRAME_SIZE_ERROR.
-pub async fn sends_window_update_frame_with_invalid_length<IO: IntoHalves + 'static>(
+pub async fn sends_window_update_frame_with_invalid_length<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -664,7 +658,7 @@ pub async fn sends_window_update_frame_with_invalid_length<IO: IntoHalves + 'sta
 /// that exceeds the space available in either of the flow-control
 /// windows advertised by the receiver.
 pub async fn sends_settings_frame_to_set_initial_window_size_to_1_and_sends_headers_frame<
-    IO: IntoHalves + 'static,
+    IO: IntoHalves,
 >(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
@@ -696,7 +690,7 @@ pub async fn sends_settings_frame_to_set_initial_window_size_to_1_and_sends_head
 /// of FLOW_CONTROL_ERROR; for the connection, a GOAWAY frame with
 /// an error code of FLOW_CONTROL_ERROR is sent.
 pub async fn sends_multiple_window_update_frames_increasing_flow_control_window_above_max<
-    IO: IntoHalves + 'static,
+    IO: IntoHalves,
 >(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
@@ -721,7 +715,7 @@ pub async fn sends_multiple_window_update_frames_increasing_flow_control_window_
 /// of FLOW_CONTROL_ERROR; for the connection, a GOAWAY frame with
 /// an error code of FLOW_CONTROL_ERROR is sent.
 pub async fn sends_multiple_window_update_frames_increasing_flow_control_window_above_max_on_stream<
-    IO: IntoHalves + 'static,
+    IO: IntoHalves,
 >(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
@@ -751,9 +745,7 @@ pub async fn sends_multiple_window_update_frames_increasing_flow_control_window_
 /// a receiver MUST adjust the size of all stream flow-control
 /// windows that it maintains by the difference between the new
 /// value and the old value.
-pub async fn changes_settings_initial_window_size_after_sending_headers_frame<
-    IO: IntoHalves + 'static,
->(
+pub async fn changes_settings_initial_window_size_after_sending_headers_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -786,7 +778,7 @@ pub async fn changes_settings_initial_window_size_after_sending_headers_frame<
 /// MUST NOT send new flow-controlled frames until it receives
 /// WINDOW_UPDATE frames that cause the flow-control window to
 /// become positive.
-pub async fn sends_settings_frame_for_window_size_to_be_negative<IO: IntoHalves + 'static>(
+pub async fn sends_settings_frame_for_window_size_to_be_negative<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -823,7 +815,7 @@ pub async fn sends_settings_frame_for_window_size_to_be_negative<IO: IntoHalves
 /// that causes any flow-control window to exceed the maximum size
 /// as a connection error (Section 5.4.1) of type FLOW_CONTROL_ERROR.
 pub async fn sends_settings_initial_window_size_with_exceeded_max_window_size_value<
-    IO: IntoHalves + 'static,
+    IO: IntoHalves,
 >(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
@@ -847,9 +839,7 @@ pub async fn sends_settings_initial_window_size_with_exceeded_max_window_size_va
 /// CONTINUATION frames can be sent, as long as the preceding frame
 /// is on the same stream and is a HEADERS, PUSH_PROMISE,
 /// or CONTINUATION frame without the END_HEADERS flag set.
-pub async fn sends_multiple_continuation_frames_preceded_by_headers_frame<
-    IO: IntoHalves + 'static,
->(
+pub async fn sends_multiple_continuation_frames_preceded_by_headers_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -879,9 +869,7 @@ pub async fn sends_multiple_continuation_frames_preceded_by_headers_frame<
 /// by another CONTINUATION frame. A receiver MUST treat the receipt
 /// of any other type of frame or a frame on a different stream as
 /// a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_continuation_frame_followed_by_non_continuation_frame<
-    IO: IntoHalves + 'static,
->(
+pub async fn sends_continuation_frame_followed_by_non_continuation_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -907,7 +895,7 @@ pub async fn sends_continuation_frame_followed_by_non_continuation_frame<
 /// CONTINUATION frame is received whose stream identifier field is
 /// 0x0, the recipient MUST respond with a connection error
 /// (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_continuation_frame_with_zero_stream_id<IO: IntoHalves + 'static>(
+pub async fn sends_continuation_frame_with_zero_stream_id<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
@@ -936,7 +924,7 @@ pub async fn sends_continuation_frame_with_zero_stream_id<IO: IntoHalves + 'stat
 /// A recipient that observes violation of this rule MUST respond
 /// with a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
 pub async fn sends_continuation_frame_preceded_by_headers_frame_with_end_headers_flag<
-    IO: IntoHalves + 'static,
+    IO: IntoHalves,
 >(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
@@ -967,7 +955,7 @@ pub async fn sends_continuation_frame_preceded_by_headers_frame_with_end_headers
 /// A recipient that observes violation of this rule MUST respond
 /// with a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
 pub async fn sends_continuation_frame_preceded_by_continuation_frame_with_end_headers_flag<
-    IO: IntoHalves + 'static,
+    IO: IntoHalves,
 >(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
@@ -996,7 +984,7 @@ pub async fn sends_continuation_frame_preceded_by_continuation_frame_with_end_he
 /// or CONTINUATION frame without the END_HEADERS flag set.
 /// A recipient that observes violation of this rule MUST respond
 /// with a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
-pub async fn sends_continuation_frame_preceded_by_data_frame<IO: IntoHalves + 'static>(
+pub async fn sends_continuation_frame_preceded_by_data_frame<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
diff --git a/crates/httpwg/src/rfc9113/_7_error_codes.rs b/crates/httpwg/src/rfc9113/_7_error_codes.rs
index 10262bf6..db621f78 100644
--- a/crates/httpwg/src/rfc9113/_7_error_codes.rs
+++ b/crates/httpwg/src/rfc9113/_7_error_codes.rs
@@ -8,7 +8,7 @@ use crate::Conn;
 /// Unknown or unsupported error codes MUST NOT trigger any special
 /// behavior. These MAY be treated by an implementation as being
 /// equivalent to INTERNAL_ERROR.
-pub async fn sends_goaway_frame_with_unknown_error_code<IO: IntoHalves + 'static>(
+pub async fn sends_goaway_frame_with_unknown_error_code<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     conn.handshake().await?;
@@ -31,7 +31,7 @@ pub async fn sends_goaway_frame_with_unknown_error_code<IO: IntoHalves + 'static
 /// Unknown or unsupported error codes MUST NOT trigger any special
 /// behavior. These MAY be treated by an implementation as being
 /// equivalent to INTERNAL_ERROR.
-pub async fn sends_rst_stream_frame_with_unknown_error_code<IO: IntoHalves + 'static>(
+pub async fn sends_rst_stream_frame_with_unknown_error_code<IO: IntoHalves>(
     mut conn: Conn<IO>,
 ) -> eyre::Result<()> {
     let stream_id = StreamId(1);
diff --git a/crates/httpwg/src/rfc9113/_8_expressing_http_semantics_in_http2.rs b/crates/httpwg/src/rfc9113/_8_expressing_http_semantics_in_http2.rs
new file mode 100644
index 00000000..7c43286b
--- /dev/null
+++ b/crates/httpwg/src/rfc9113/_8_expressing_http_semantics_in_http2.rs
@@ -0,0 +1,790 @@
+//! Section 8: Expressing HTTP Semantics in HTTP/2
+
+use std::io::Write;
+
+use fluke_buffet::IntoHalves;
+use fluke_h2_parse::{pack_bit_and_u31, FrameType, HeadersFlags, StreamId};
+
+use crate::{Conn, ErrorC, FrameT, Headers};
+
+//---- Section 8.1: HTTP Message Framing
+
+// An endpoint that receives a HEADERS frame without the
+// END_STREAM flag set after receiving a final (non-informational)
+// status code MUST treat the corresponding request or response
+// as malformed (Section 8.1.2.6).
+pub async fn sends_second_headers_frame_without_end_stream<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    let stream_id = StreamId(1);
+    conn.handshake().await?;
+
+    let headers_fragment = conn.encode_headers(&conn.common_headers("POST"))?;
+    conn.write_headers(stream_id, HeadersFlags::EndHeaders, headers_fragment)
+        .await?;
+    conn.write_data(stream_id, false, b"test").await?;
+
+    let mut trailers = Headers::default();
+    trailers.append("x-test", "ok");
+    let trailers_fragment = conn.encode_headers(&trailers)?;
+    conn.write_headers(stream_id, HeadersFlags::EndHeaders, trailers_fragment)
+        .await?;
+
+    conn.verify_stream_error(ErrorC::ProtocolError).await?;
+
+    Ok(())
+}
+
+//--- Section 8.2.1: Field Validity
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_uppercase_field_name<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("UPPERCASE", "oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_space_in_field_name<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("space force", "oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_non_visible_ascii<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("\x01invalid", "oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_del_character<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("\x7Finvalid", "oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field name MUST NOT contain characters in the ranges 0x00-0x20, 0x41-0x5a,
+/// or 0x7f-0xff (all ranges inclusive). This specifically excludes all
+/// non-visible ASCII characters, ASCII SP (0x20), and uppercase characters ('A'
+/// to 'Z', ASCII 0x41 to 0x5a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_non_ascii_character<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("inválid", "oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// With the exception of pseudo-header fields (Section 8.3), which have a name
+/// that starts with a single colon, field names MUST NOT include a colon (ASCII
+/// COLON, 0x3a).
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_colon_in_field_name<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("invalid:field", "oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+/// (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_lf_in_field_value<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("invalid-value", "oh\nno");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+/// (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_cr_in_field_value<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("invalid-value", "oh\rno");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed
+/// (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.
+///
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_nul_in_field_value<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("invalid-value", "oh\0no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field value MUST NOT start or end with an ASCII whitespace character
+/// (ASCII SP or HTAB, 0x20 or 0x09).
+
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_leading_space_in_field_value<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("invalid-value", " oh no");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A field value MUST NOT start or end with an ASCII whitespace character
+/// (ASCII SP or HTAB, 0x20 or 0x09).
+
+/// When a request message violates one of these requirements, an implementation
+/// SHOULD generate a 400 (Bad Request) status code (see Section 15.5.1 of
+/// [HTTP]), unless a more suitable status code is defined or the status code
+/// cannot be sent (e.g., because the error occurs in a trailer field).
+pub async fn sends_headers_frame_with_trailing_tab_in_field_value<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("invalid-value", "oh no\t");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+//---- Section 8.2.2: Connection-Specific Header Fields
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+pub async fn sends_headers_frame_with_connection_header<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("connection", "keep-alive");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+pub async fn sends_headers_frame_with_proxy_connection_header<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("proxy-connection", "keep-alive");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+pub async fn sends_headers_frame_with_keep_alive_header<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("keep-alive", "timeout=5");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+pub async fn sends_headers_frame_with_transfer_encoding_header<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("transfer-encoding", "chunked");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// HTTP/2 does not use the Connection header field (Section 7.6.1 of [HTTP]) to
+/// indicate connection-specific header fields; in this protocol,
+/// connection-specific metadata is conveyed by other means. An endpoint MUST
+/// NOT generate an HTTP/2 message containing connection-specific header fields.
+///
+/// This includes the Connection header field and those listed as having
+/// connection-specific semantics in Section 7.6.1 of [HTTP] (that is,
+/// Proxy-Connection, Keep-Alive, Transfer-Encoding, and Upgrade). Any message
+/// containing connection-specific header fields MUST be treated as malformed
+/// (Section 8.1.1).
+pub async fn sends_headers_frame_with_upgrade_header<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("upgrade", "h2c");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// The only exception to this is the TE header field, which MAY be present in
+/// an HTTP/2 request; when it is, it MUST NOT contain any value other than
+/// "trailers".
+pub async fn sends_headers_frame_with_te_trailers<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("te", "trailers");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 200)
+        .await?;
+
+    Ok(())
+}
+
+/// The only exception to this is the TE header field, which MAY be present in
+/// an HTTP/2 request; when it is, it MUST NOT contain any value other than
+/// "trailers".
+pub async fn sends_headers_frame_with_te_not_trailers<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append("te", "not-trailers");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+//---- Section 8.2.3: Compressing the Cookie Header Field
+
+// That can't really be tested without controlling both sides of the
+// connection, so, not suited for this test suite.
+
+//---- Section 8.3: HTTP Control Data
+
+/// [...] pseudo-header fields defined for responses MUST NOT appear in requests
+/// [...] Endpoints MUST treat a request or response that contains undefined or
+/// invalid pseudo-header fields as malformed (Section 8.1.1).
+pub async fn sends_headers_frame_with_response_pseudo_header<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.append(":status", "200");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// [...] Pseudo-header fields MUST NOT appear in a trailer section. Endpoints
+/// MUST treat a request or response that contains undefined or invalid
+/// pseudo-header fields as malformed (Section 8.1.1).
+pub async fn sends_headers_frame_with_pseudo_header_in_trailer<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    let stream_id = StreamId(1);
+    conn.handshake().await?;
+
+    let headers_fragment = conn.encode_headers(&conn.common_headers("POST"))?;
+    conn.write_headers(stream_id, HeadersFlags::EndHeaders, headers_fragment)
+        .await?;
+    conn.write_data(stream_id, false, b"test").await?;
+
+    let mut trailers = Headers::default();
+    trailers.append(":method", "POST");
+    let trailers_fragment = conn.encode_headers(&trailers)?;
+    conn.write_headers(
+        stream_id,
+        HeadersFlags::EndHeaders | HeadersFlags::EndStream,
+        trailers_fragment,
+    )
+    .await?;
+
+    // wait for headers frame, expect 400 status
+    let (frame, payload) = conn.wait_for_frame(FrameT::Headers).await.unwrap();
+    assert!(frame.is_end_headers(), "this test makes that assumption");
+    let headers = conn.decode_headers(payload.into())?;
+    let status = headers.get_first(&":status".into()).unwrap();
+    let status = std::str::from_utf8(status)?;
+    let status = status.parse::<u16>().unwrap();
+    assert_eq!(status, 400);
+
+    Ok(())
+}
+
+/// The same pseudo-header field name MUST NOT appear more than once in a field
+/// block. A field block for an HTTP request or response that contains a
+/// repeated pseudo-header field name MUST be treated as malformed (Section
+/// 8.1.1).
+pub async fn sends_headers_frame_with_duplicate_pseudo_headers<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.prepend(":method", "POST");
+    headers.prepend(":method", "POST");
+
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// A server SHOULD treat a request as malformed if it contains a Host header
+/// field that identifies an entity that differs from the entity in the
+/// ":authority" pseudo-header field. The values of fields need to be normalized
+/// to compare them (see Section 6.2 of [RFC3986]). An origin server can apply
+/// any normalization method, whereas other servers MUST perform scheme-based
+/// normalization (see Section 6.2.3 of [RFC3986]) of the two fields.
+///
+/// cf. <https://www.rfc-editor.org/rfc/rfc3986.html#section-6.2.3>
+pub async fn sends_headers_frame_with_mismatched_host_authority<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.replace(":authority", conn.config.host.clone().into_bytes());
+    headers.append(
+        "host",
+        format!("{}.different", conn.config.host).into_bytes(),
+    );
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+//---
+// Note: URI normalization is a can of worms, see <https://www.rfc-editor.org/rfc/rfc3986.html#section-6.2>
+// It seems hard to test it in a way that most HTTP/2 implementations would
+// pass.
+//---
+
+/// This pseudo-header field MUST NOT be empty for "http" or "https" URIs;
+/// "http" or "https" URIs that do not contain a path component MUST include a
+/// value of '/'. The exceptions to this rule are:
+///
+/// an OPTIONS request for an "http" or "https" URI that does not include a path
+/// component; these MUST include a ":path" pseudo-header field with a value of
+/// '*' (see Section 7.1 of [HTTP]). CONNECT requests (Section 8.5), where the
+/// ":path" pseudo-header field is omitted.
+pub async fn sends_headers_frame_with_empty_path_component<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.replace(":path", "");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// All HTTP/2 requests MUST include exactly one valid value for the ":method",
+/// ":scheme", and ":path" pseudo-header fields, unless they are CONNECT
+/// requests (Section 8.5). An HTTP request that omits mandatory pseudo-header
+/// fields is malformed (Section 8.1.1).
+
+pub async fn sends_headers_frame_without_method<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.remove(&":method".into());
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+pub async fn sends_headers_frame_without_scheme<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.remove(&":scheme".into());
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+pub async fn sends_headers_frame_without_path<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers("POST");
+    headers.remove(&":path".into());
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+//---- Section 8.3.2: Response Pseudo-Header Fields
+
+pub async fn sends_headers_frame_without_status<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let block_fragment = conn.encode_headers(&conn.common_headers("POST"))?;
+    conn.write_frame(
+        FrameType::Headers(HeadersFlags::EndStream | HeadersFlags::EndHeaders)
+            .into_frame(StreamId(1)),
+        block_fragment,
+    )
+    .await?;
+
+    // wait for the response
+    let (frame, payload) = conn.wait_for_frame(FrameT::Headers).await.unwrap();
+    assert!(frame.is_end_headers(), "the test makes that assumption");
+    let headers = conn.decode_headers(payload.into())?;
+
+    let mut found_status = false;
+    for (name, _) in headers.iter() {
+        if name == b":status" {
+            found_status = true;
+        } else {
+            assert!(
+                !name.starts_with(b":"),
+                "no header name should start with ':'"
+            );
+        }
+    }
+    assert!(found_status, "the :status pseudo-header must be present");
+
+    Ok(())
+}
+
+//--- Section 8.4: Server Push
+
+// Server push is discouraged now:
+//
+// In practice, server push is difficult to use effectively, because it requires
+// the server to correctly anticipate the additional requests the client will
+// make, taking into account factors such as caching, content negotiation, and
+// user behavior. Errors in prediction can lead to performance degradation, due
+// to the opportunity cost that the additional data on the wire represents. In
+// particular, pushing any significant amount of data can cause contention
+// issues with responses that are more important.
+
+/// A client cannot push. Thus, servers MUST treat the receipt of a PUSH_PROMISE
+/// frame as a connection error (Section 5.4.1) of type PROTOCOL_ERROR. A server
+/// cannot set the SETTINGS_ENABLE_PUSH setting to a value other than 0 (see
+/// Section 6.5.2).
+pub async fn client_sends_push_promise_frame<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let stream_id = StreamId(1);
+    let promised_stream_id = StreamId(2);
+
+    let mut headers = Headers::default();
+    headers.append(":status", "200");
+    let block_fragment = conn.encode_headers(&headers)?;
+    let payload = conn
+        .scratch
+        .put_to_roll(block_fragment.len() + 4, |mut s| {
+            s.write_all(&pack_bit_and_u31(0, promised_stream_id.0))?;
+            s.write_all(&block_fragment)?;
+            Ok(())
+        })?;
+    conn.write_frame(FrameType::PushPromise.into_frame(stream_id), payload)
+        .await?;
+
+    conn.verify_connection_error(ErrorC::ProtocolError).await?;
+
+    Ok(())
+}
+
+//---- Section 8.5: The CONNECT Method
+
+/// The CONNECT method (Section 9.3.6 of [HTTP]) is used to convert an HTTP
+/// connection into a tunnel to a remote host. CONNECT is primarily used with
+/// HTTP proxies to establish a TLS session with an origin server for the
+/// purposes of interacting with "https" resources.
+///
+/// In HTTP/2, the CONNECT method establishes a tunnel over a single HTTP/2
+/// stream to a remote host, rather than converting the entire connection to a
+/// tunnel. A CONNECT header section is constructed as defined in Section 8.3.1
+/// ("Request Pseudo-Header Fields"), with a few differences. Specifically:
+///
+/// The ":method" pseudo-header field is set to CONNECT.
+/// The ":scheme" and ":path" pseudo-header fields MUST be omitted.
+/// The ":authority" pseudo-header field contains the host and port to connect
+/// to (equivalent to the authority-form of the request-target of CONNECT
+/// requests; see Section 3.2.3 of [HTTP/1.1]).
+
+pub async fn sends_connect_with_scheme<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = Headers::default();
+    headers.append(":method", "CONNECT");
+    headers.append(":scheme", "https");
+    headers.append(":authority", "example.com:443");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+pub async fn sends_connect_with_path<IO: IntoHalves>(mut conn: Conn<IO>) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = Headers::default();
+    headers.append(":method", "CONNECT");
+    headers.append(":path", "/");
+    headers.append(":authority", "example.com:443");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+pub async fn sends_connect_without_authority<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = Headers::default();
+    headers.append(":method", "CONNECT");
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+/// All pseudo-header fields MUST appear in a field block before all regular
+/// field lines (RFC 9113, section 8.3)
+pub async fn sends_headers_frame_with_pseudo_headers_after_regular_headers<IO: IntoHalves>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.handshake().await?;
+
+    let mut headers = Headers::default();
+    headers.append(":method", "POST");
+    headers.append(":path", "/");
+    headers.append("content-type", "application/json");
+    headers.append(":authority", "example.com");
+
+    conn.send_req_and_expect_status(StreamId(1), &headers, 400)
+        .await?;
+
+    Ok(())
+}
+
+//---- Section 8.6: The Upgrade Header Field
+
+// (No tests for this section: 101 is not a thing in HTTP/2)
+
+//---- Section 8.7: Request Reliability
+
+// HTTP/2 provides two mechanisms for providing a guarantee to a client that a
+// request has not been processed:
+//
+// The GOAWAY frame indicates the highest stream number that might have been
+// processed. Requests on streams with higher numbers are therefore guaranteed
+// to be safe to retry. The REFUSED_STREAM error code can be included in a
+// RST_STREAM frame to indicate that the stream is being closed prior to any
+// processing having occurred. Any request that was sent on the reset stream
+// can be safely retried. Requests that have not been processed have not
+// failed; clients MAY automatically retry them, even those with non-idempotent
+// methods.
+//
+// A server MUST NOT indicate that a stream has not been processed unless it
+// can guarantee that fact. If frames that are on a stream are passed to the
+// application layer for any stream, then REFUSED_STREAM MUST NOT be used for
+// that stream, and a GOAWAY frame MUST include a stream identifier that is
+// greater than or equal to the given stream identifier.
+//
+// ------------
+//
+// Note: this feels impossible to test while only controlling the client,
+// because: we cannot force the server to send us REFUSED_STREAM (short of
+// opening potentially hundreds of streams — and even then, it might just get
+// overloaded rather than starting to refuse streams).
+//
+// We can't force the server to send us GOAWAY either, because it's allowed to
+// close the connection without sending one, cf. Section 6.8:
+//
+//   > An endpoint might choose to close a connection without sending a GOAWAY
+//   > for misbehaving peers.
+//
+// Testing the server would only be possible if it had a
+// `/.well-known/initiate-graceful-shutdown` endpoint, and additionally if we
+// could check what requests were _actually_ partially processed (most likely
+// forwarded to an origin server).
+//
+// This feels like a good integration test for a proxy server, but it's not
+// suitable for this suite (no pun intended).
diff --git a/crates/httpwg/src/rfc9113/mod.rs b/crates/httpwg/src/rfc9113/mod.rs
index 39786211..ecec3fb2 100644
--- a/crates/httpwg/src/rfc9113/mod.rs
+++ b/crates/httpwg/src/rfc9113/mod.rs
@@ -30,3 +30,4 @@ pub mod _4_http_frames;
 pub mod _5_streams_and_multiplexing;
 pub mod _6_frame_definitions;
 pub mod _7_error_codes;
+pub mod _8_expressing_http_semantics_in_http2;


Test patch:
diff --git a/crates/fluke-curl-tests/tests/integration_test.rs b/crates/fluke-curl-tests/tests/integration_test.rs
index 261a8a8c..eec08d53 100644
--- a/crates/fluke-curl-tests/tests/integration_test.rs
+++ b/crates/fluke-curl-tests/tests/integration_test.rs
@@ -912,9 +912,9 @@ impl Body for SampleBody {
     async fn next_chunk(&mut self) -> eyre::Result<BodyChunk> {
         let c = match self.chunks_remain {
             0 => BodyChunk::Done { trailers: None },
-            _ => BodyChunk::Chunk(PieceCore::Vec(Rc::new(
-                b"this is a big chunk".to_vec().repeat(256),
-            ))),
+            _ => BodyChunk::Chunk(
+                PieceCore::Vec(Rc::new(b"this is a big chunk".to_vec().repeat(256))).into(),
+            ),
         };
 
         if let Some(remain) = self.chunks_remain.checked_sub(1) {
diff --git a/crates/fluke/tests/httpwg.rs b/crates/fluke/tests/httpwg.rs
index c7b8f9ad..b471f5fd 100644
--- a/crates/fluke/tests/httpwg.rs
+++ b/crates/fluke/tests/httpwg.rs
@@ -90,7 +90,7 @@ impl fluke::ServerDriver for TestDriver {
 }
 
 pub struct TwoHalves<W, R>(W, R);
-impl<W: WriteOwned, R: ReadOwned> IntoHalves for TwoHalves<W, R> {
+impl<W: WriteOwned + 'static, R: ReadOwned + 'static> IntoHalves for TwoHalves<W, R> {
     type Read = R;
     type Write = W;