Repository: bearcove/loona (language: rust)

Issue:
Bring HTTP/2 specs in-house
In the time it took me to work around the problems in the existing h2spec + junit reporters, I probably could've ported all the tests cases to this codebase.

The implementation feels at a point where it's safe to do that. Note that the spec will definitely depend on some of fluke's own types, but that's fine.

---

edit: oh also, let's use the _current_ RFCs this time around, ie. RFC 9110-9114.

(And 7541 for hpack I suppose).


Discussion / hints:

Fix patch:
diff --git a/.envrc b/.envrc
deleted file mode 100644
index 3550a30f..00000000
--- a/.envrc
+++ /dev/null
@@ -1,1 +0,0 @@
-use flake
diff --git a/.gitignore b/.gitignore
index f5cf10c5..43880ae4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,10 @@
 coverage
 target
 target-cov
+target-codegen
 *.lcov
 lcov.info
 junit.xml
 .direnv
 .DS_Store
+/vendor
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 55fc1f9c..9638c203 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,10 +3,6 @@
 fluke is still experimental, but you can help by trying to build
 stuff with it.
 
-The highest-impact work right now is probably picking up an h2spec
-case and making it pass, or tackling some of the `TODO:` or `FIXME:`
-in the code.
-
 More test coverage is always welcome, it's tracked via llvm-cov, GitHub
 actions will comment on PR with coverage changes, and you can see the
 coverage for yourself with `just cov`, in the `coverage/html` directory.
diff --git a/Cargo.lock b/Cargo.lock
index 304b70c3..652e1602 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -149,12 +149,6 @@ version = "0.1.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
 
-[[package]]
-name = "either"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
-
 [[package]]
 name = "enum-repr"
 version = "0.2.6"
@@ -192,16 +186,6 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
 
-[[package]]
-name = "errno"
-version = "0.3.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
-
 [[package]]
 name = "eyre"
 version = "0.6.12"
@@ -218,14 +202,16 @@ version = "0.1.0"
 dependencies = [
  "byteorder",
  "bytes",
- "enum-repr",
- "enumflags2",
+ "color-eyre",
  "eyre",
  "fluke-buffet",
+ "fluke-h2-parse",
  "fluke-hpack",
  "futures-util",
  "http 1.1.0",
  "httparse",
+ "httpwg",
+ "httpwg-macros",
  "libc",
  "memchr",
  "nom",
@@ -284,15 +270,16 @@ dependencies = [
 ]
 
 [[package]]
-name = "fluke-h2spec"
+name = "fluke-h2-parse"
 version = "0.1.0"
 dependencies = [
- "color-eyre",
- "fluke",
- "tokio",
+ "byteorder",
+ "enum-repr",
+ "enumflags2",
+ "fluke-buffet",
+ "nom",
+ "thiserror",
  "tracing",
- "tracing-subscriber",
- "which",
 ]
 
 [[package]]
@@ -327,19 +314,6 @@ dependencies = [
  "tokio",
 ]
 
-[[package]]
-name = "fluke-sample-h2-server"
-version = "0.1.0"
-dependencies = [
- "bytes",
- "color-eyre",
- "hyper",
- "tokio",
- "tokio-stream",
- "tracing",
- "tracing-subscriber",
-]
-
 [[package]]
 name = "fluke-tls-sample"
 version = "0.1.0"
@@ -470,25 +444,6 @@ version = "0.28.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
 
-[[package]]
-name = "h2"
-version = "0.3.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
-dependencies = [
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "futures-util",
- "http 0.2.12",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
 [[package]]
 name = "hashbrown"
 version = "0.14.3"
@@ -507,15 +462,6 @@ version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
-[[package]]
-name = "home"
-version = "0.5.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
-dependencies = [
- "windows-sys 0.52.0",
-]
-
 [[package]]
 name = "http"
 version = "0.2.12"
@@ -561,6 +507,38 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
+[[package]]
+name = "httpwg"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "enumflags2",
+ "eyre",
+ "fluke-buffet",
+ "fluke-h2-parse",
+ "fluke-hpack",
+ "futures-util",
+ "multimap",
+ "pretty-hex",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "httpwg-gen"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "httpwg-macros"
+version = "0.1.0"
+dependencies = [
+ "httpwg",
+]
+
 [[package]]
 name = "hyper"
 version = "0.14.28"
@@ -571,7 +549,6 @@ dependencies = [
  "futures-channel",
  "futures-core",
  "futures-util",
- "h2",
  "http 0.2.12",
  "http-body",
  "httparse",
@@ -674,12 +651,6 @@ version = "0.2.153"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
 
-[[package]]
-name = "linux-raw-sys"
-version = "0.4.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
-
 [[package]]
 name = "lock_api"
 version = "0.4.11"
@@ -755,6 +726,12 @@ 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"
@@ -1057,19 +1034,6 @@ version = "0.1.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
 
-[[package]]
-name = "rustix"
-version = "0.38.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89"
-dependencies = [
- "bitflags 2.5.0",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys 0.52.0",
-]
-
 [[package]]
 name = "rustls"
 version = "0.23.5"
@@ -1235,18 +1199,18 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "1.0.58"
+version = "1.0.60"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
+checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.58"
+version = "1.0.60"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
+checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1284,9 +1248,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
 
 [[package]]
 name = "tokio"
-version = "1.36.0"
+version = "1.37.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
+checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
 dependencies = [
  "backtrace",
  "bytes",
@@ -1333,20 +1297,6 @@ dependencies = [
  "tokio",
 ]
 
-[[package]]
-name = "tokio-util"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-sink",
- "pin-project-lite",
- "tokio",
- "tracing",
-]
-
 [[package]]
 name = "toml_datetime"
 version = "0.6.5"
@@ -1550,18 +1500,6 @@ dependencies = [
  "wasm-bindgen",
 ]
 
-[[package]]
-name = "which"
-version = "6.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8211e4f58a2b2805adfbefbc07bab82958fc91e3836339b1ab7ae32465dce0d7"
-dependencies = [
- "either",
- "home",
- "rustix",
- "winsafe",
-]
-
 [[package]]
 name = "winapi"
 version = "0.3.9"
@@ -1725,12 +1663,6 @@ dependencies = [
  "memchr",
 ]
 
-[[package]]
-name = "winsafe"
-version = "0.0.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
-
 [[package]]
 name = "yansi"
 version = "0.5.1"
diff --git a/Justfile b/Justfile
index 1efe6d25..7ae8a808 100644
--- a/Justfile
+++ b/Justfile
@@ -10,10 +10,13 @@ ci-test:
     just cov
 
 cov:
-	scripts/cov.sh
-
-quick-cov:
-	SKIP_H2SPEC=1 scripts/cov.sh
+    #!/bin/bash -eux
+    just build-testbed
+    export RUSTUP_TOOLCHAIN=nightly-2024-05-26
+    rm -rf coverage
+    mkdir -p coverage
+    cargo llvm-cov nextest --branch --ignore-filename-regex '.*crates/(httpwg|fluke-hyper-testbed|fluke-tls-sample|fluke-sample-h2-server).*' --html --output-dir=coverage
+    cargo llvm-cov report --lcov --output-path 'coverage/lcov.info'
 
 # Run all tests with cargo nextest
 test *args:
@@ -27,15 +30,12 @@ build-testbed:
 single-test *args:
 	just test --no-capture {{args}}
 
-h2spec *args:
-	#!/bin/bash -eux
-	export RUST_LOG="${RUST_LOG:-fluke=debug,fluke_hpack=info}"
-	export RUST_BACKTRACE="${RUST_BACKTRACE:-1}"
-	cargo run -p fluke-h2spec -- {{args}}
-
 check:
 	#!/bin/bash -eu
 	cargo clippy --all-targets --all-features
 
 tls-sample:
 	cargo run -p fluke-tls-sample
+
+httpwg-gen:
+    cargo run --release --package httpwg-gen
diff --git a/codecov.yml b/codecov.yml
index b9e5ecb2..b7847056 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,5 +1,4 @@
 ignore:
-  - "crates/fluke-h2spec/**"
   - "crates/fluke-tls-sample/**"
   - "crates/fluke-hyper-testbed/**"
   - "crates/fluke-curl-tests/**"
diff --git a/crates/fluke-buffet/src/bufpool.rs b/crates/fluke-buffet/src/bufpool.rs
index 0ee8aebe..55df4400 100644
--- a/crates/fluke-buffet/src/bufpool.rs
+++ b/crates/fluke-buffet/src/bufpool.rs
@@ -31,6 +31,9 @@ pub enum Error {
 
     #[error("out of memory")]
     OutOfMemory,
+
+    #[error("slice does not fit into this RollMut")]
+    DoesNotFit,
 }
 
 /// A buffer pool
diff --git a/crates/fluke-buffet/src/io.rs b/crates/fluke-buffet/src/io.rs
index 6e92d426..92c60156 100644
--- a/crates/fluke-buffet/src/io.rs
+++ b/crates/fluke-buffet/src/io.rs
@@ -1,29 +1,28 @@
-use std::net::Shutdown;
-
 use crate::{BufResult, IoBufMut, Piece, PieceList};
 
-mod chan;
-pub use chan::*;
+mod pipe;
+pub use pipe::*;
 
 mod non_uring;
 
 #[allow(async_fn_in_trait)] // we never require Send
 pub trait ReadOwned {
-    async fn read<B: IoBufMut>(&mut self, buf: B) -> BufResult<usize, B>;
+    async fn read_owned<B: IoBufMut>(&mut self, buf: B) -> BufResult<usize, B>;
 }
 
 #[allow(async_fn_in_trait)] // we never require Send
 pub trait WriteOwned {
     /// Write a single buffer, taking ownership for the duration of the write.
     /// Might perform a partial write, see [WriteOwned::write_all]
-    async fn write(&mut self, buf: Piece) -> BufResult<usize, Piece>;
+    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.
-    async fn write_all(&mut self, mut buf: Piece) -> std::io::Result<()> {
+    async fn write_all_owned(&mut self, buf: impl Into<Piece>) -> std::io::Result<()> {
+        let mut buf = buf.into();
         let mut written = 0;
         let len = buf.len();
         while written < len {
-            let (res, slice) = self.write(buf).await;
+            let (res, slice) = self.write_owned(buf).await;
             let n = res?;
             if n == 0 {
                 return Err(std::io::Error::new(
@@ -39,12 +38,12 @@ pub trait WriteOwned {
 
     /// Write a list of buffers, taking ownership for the duration of the write.
     /// Might perform a partial write, see [WriteOwned::writev_all]
-    async fn writev(&mut self, list: &PieceList) -> std::io::Result<usize> {
+    async fn writev_owned(&mut self, list: &PieceList) -> std::io::Result<usize> {
         let mut total = 0;
 
         for buf in list.pieces.iter().cloned() {
             let buf_len = buf.len();
-            let (res, _) = self.write(buf).await;
+            let (res, _) = self.write_owned(buf).await;
 
             match res {
                 Ok(0) => {
@@ -70,9 +69,9 @@ pub trait WriteOwned {
     }
 
     /// Write a list of buffers, re-trying the write if the kernel does a partial write.
-    async fn writev_all(&mut self, mut list: PieceList) -> std::io::Result<()> {
+    async fn writev_all_owned(&mut self, mut list: PieceList) -> std::io::Result<()> {
         while !list.is_empty() {
-            let n = self.writev(&list).await?;
+            let n = self.writev_owned(&list).await?;
             if n == 0 {
                 return Err(std::io::Error::new(
                     std::io::ErrorKind::WriteZero,
@@ -104,7 +103,9 @@ pub trait WriteOwned {
         Ok(())
     }
 
-    async fn shutdown(&mut self, how: Shutdown) -> std::io::Result<()>;
+    /// Shuts down the write end of this socket. This flushes
+    /// any data that may not have been send.
+    async fn shutdown(&mut self) -> std::io::Result<()>;
 }
 
 #[cfg(all(test, not(feature = "miri")))]
@@ -126,7 +127,8 @@ mod tests {
         }
 
         impl WriteOwned for Writer {
-            async fn write(&mut self, buf: Piece) -> BufResult<usize, Piece> {
+            async fn write_owned(&mut self, buf: impl Into<Piece>) -> BufResult<usize, Piece> {
+                let buf = buf.into();
                 assert!(!buf.is_empty(), "zero-length writes are forbidden");
 
                 match self.mode {
@@ -142,7 +144,7 @@ mod tests {
                 }
             }
 
-            async fn shutdown(&mut self, _how: std::net::Shutdown) -> std::io::Result<()> {
+            async fn shutdown(&mut self) -> std::io::Result<()> {
                 Ok(())
             }
         }
@@ -153,7 +155,7 @@ mod tests {
                 bytes: Default::default(),
             };
             let buf_a = vec![1, 2, 3, 4, 5];
-            let res = writer.write_all(buf_a.into()).await;
+            let res = writer.write_all_owned(buf_a).await;
             assert!(res.is_err());
 
             let mut writer = Writer {
@@ -163,7 +165,7 @@ mod tests {
             let buf_a = vec![1, 2, 3, 4, 5];
             let buf_b = vec![6, 7, 8, 9, 10];
             let res = writer
-                .writev_all(PieceList::single(buf_a).followed_by(buf_b))
+                .writev_all_owned(PieceList::single(buf_a).followed_by(buf_b))
                 .await;
             assert!(res.is_err());
 
@@ -172,7 +174,7 @@ mod tests {
                 bytes: Default::default(),
             };
             let buf_a = vec![1, 2, 3, 4, 5];
-            writer.write_all(buf_a.into()).await.unwrap();
+            writer.write_all_owned(buf_a).await.unwrap();
             assert_eq!(&writer.bytes.borrow()[..], &[1, 2, 3, 4, 5]);
 
             let mut writer = Writer {
@@ -182,7 +184,7 @@ mod tests {
             let buf_a = vec![1, 2, 3, 4, 5];
             let buf_b = vec![6, 7, 8, 9, 10];
             writer
-                .writev_all(PieceList::single(buf_a).followed_by(buf_b))
+                .writev_all_owned(PieceList::single(buf_a).followed_by(buf_b))
                 .await
                 .unwrap();
             assert_eq!(&writer.bytes.borrow()[..], &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
diff --git a/crates/fluke-buffet/src/io/chan.rs b/crates/fluke-buffet/src/io/chan.rs
deleted file mode 100644
index 97be8c8c..00000000
--- a/crates/fluke-buffet/src/io/chan.rs
+++ /dev/null
@@ -1,284 +0,0 @@
-use std::{cell::RefCell, rc::Rc};
-
-use crate::{BufResult, IoBufMut, Piece, WriteOwned};
-use tokio::sync::mpsc;
-
-use super::ReadOwned;
-
-/// Allows sending `Vec<u8>` chunks, which can be read through its [ReadOwned]
-/// implementation.
-pub struct ChanRead {
-    inner: Rc<ChanReadInner>,
-}
-
-pub struct ChanReadSend {
-    inner: Rc<ChanReadInner>,
-}
-
-struct ChanReadInner {
-    notify: tokio::sync::Notify,
-    guarded: RefCell<ChanReadGuarded>,
-}
-
-struct ChanReadGuarded {
-    state: ChanReadState,
-    pos: usize,
-    buf: Vec<u8>,
-}
-
-enum ChanReadState {
-    // Data may still come in
-    Live,
-
-    // [ChanReaderSend] was dropped, no more data is coming
-    Eof,
-
-    // [ChanReaderSend::rest] was called
-    Reset,
-}
-
-impl ChanRead {
-    pub fn new() -> (ChanReadSend, Self) {
-        let inner = Rc::new(ChanReadInner {
-            notify: Default::default(),
-            guarded: RefCell::new(ChanReadGuarded {
-                state: ChanReadState::Live,
-                pos: 0,
-                buf: Vec::new(),
-            }),
-        });
-        (
-            ChanReadSend {
-                inner: inner.clone(),
-            },
-            Self { inner },
-        )
-    }
-}
-
-impl ChanReadSend {
-    /// Sever this connection abnormally - read will eventually return [std::io::ErrorKind::ConnectionReset]
-    pub fn reset(self) {
-        let mut guarded = self.inner.guarded.borrow_mut();
-        guarded.state = ChanReadState::Reset;
-        // let it drop, which will notify waiters
-    }
-
-    /// Send a chunk of data. Readers will not be able to read _more_ than the
-    /// length of this chunk in a single call, but may read less (if their buffer
-    /// is too small).
-    pub async fn send(&self, next_buf: impl Into<Vec<u8>>) -> Result<(), std::io::Error> {
-        let next_buf = next_buf.into();
-
-        loop {
-            {
-                let mut guarded = self.inner.guarded.borrow_mut();
-                match guarded.state {
-                    ChanReadState::Live => {
-                        if guarded.pos == guarded.buf.len() {
-                            guarded.pos = 0;
-                            guarded.buf = next_buf;
-                            self.inner.notify.notify_waiters();
-                            return Ok(());
-                        } else {
-                            // wait for read
-                        }
-                    }
-
-                    // can't send after dropping
-                    ChanReadState::Eof => unreachable!(),
-
-                    // can't send after calling abort
-                    ChanReadState::Reset => unreachable!(),
-                }
-            }
-            self.inner.notify.notified().await
-        }
-    }
-}
-
-impl Drop for ChanReadSend {
-    fn drop(&mut self) {
-        let mut guarded = self.inner.guarded.borrow_mut();
-        if let ChanReadState::Live = guarded.state {
-            guarded.state = ChanReadState::Eof;
-        }
-        self.inner.notify.notify_waiters();
-    }
-}
-
-impl ReadOwned for ChanRead {
-    async fn read<B: IoBufMut>(&mut self, mut buf: B) -> BufResult<usize, B> {
-        let out = unsafe {
-            std::slice::from_raw_parts_mut(
-                buf.io_buf_mut_stable_mut_ptr(),
-                buf.io_buf_mut_capacity(),
-            )
-        };
-
-        loop {
-            {
-                let mut guarded = self.inner.guarded.borrow_mut();
-                let remain = guarded.buf.len() - guarded.pos;
-
-                if remain > 0 {
-                    let n = std::cmp::min(remain, out.len());
-
-                    out[..n].copy_from_slice(&guarded.buf[guarded.pos..guarded.pos + n]);
-                    guarded.pos += n;
-
-                    self.inner.notify.notify_waiters();
-                    return (Ok(n), buf);
-                }
-
-                match guarded.state {
-                    ChanReadState::Live => {
-                        // muffin
-                    }
-                    ChanReadState::Eof => {
-                        return (Ok(0), buf);
-                    }
-                    ChanReadState::Reset => {
-                        return (Err(std::io::ErrorKind::ConnectionReset.into()), buf);
-                    }
-                }
-            }
-
-            self.inner.notify.notified().await;
-        }
-    }
-}
-
-pub struct ChanWrite {
-    tx: mpsc::Sender<Vec<u8>>,
-}
-
-impl ChanWrite {
-    pub fn new() -> (mpsc::Receiver<Vec<u8>>, Self) {
-        let (tx, rx) = mpsc::channel(1);
-        (rx, Self { tx })
-    }
-}
-
-impl WriteOwned for ChanWrite {
-    async fn write(&mut self, buf: Piece) -> BufResult<usize, Piece> {
-        let v = buf[..].to_vec();
-        let v_len = v.len();
-        match self.tx.send(v).await {
-            Ok(()) => (Ok(v_len), buf),
-            Err(_) => (Err(std::io::ErrorKind::BrokenPipe.into()), buf),
-        }
-    }
-
-    async fn shutdown(&mut self, _how: std::net::Shutdown) -> std::io::Result<()> {
-        Ok(())
-    }
-}
-
-#[cfg(all(test, not(feature = "miri")))]
-mod tests {
-    use super::{ChanRead, ReadOwned};
-    use std::{cell::RefCell, rc::Rc};
-
-    #[test]
-    fn test_chan_reader() {
-        crate::start(async move {
-            let (send, mut cr) = ChanRead::new();
-            let wrote_three = Rc::new(RefCell::new(false));
-
-            crate::spawn({
-                let wrote_three = wrote_three.clone();
-                async move {
-                    send.send("one").await.unwrap();
-                    send.send("two").await.unwrap();
-                    send.send("three").await.unwrap();
-                    *wrote_three.borrow_mut() = true;
-                    send.send("splitread").await.unwrap();
-                }
-            });
-
-            {
-                let buf = vec![0u8; 256];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"one");
-            }
-
-            assert!(!*wrote_three.borrow());
-
-            {
-                let buf = vec![0u8; 256];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"two");
-            }
-
-            tokio::task::yield_now().await;
-            assert!(*wrote_three.borrow());
-
-            {
-                let buf = vec![0u8; 256];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"three");
-            }
-
-            {
-                let buf = vec![0u8; 5];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"split");
-
-                let buf = vec![0u8; 256];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"read");
-            }
-
-            {
-                let buf = vec![0u8; 0];
-                let (res, _) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(n, 0, "reached EOF");
-            }
-
-            let (send, mut cr) = ChanRead::new();
-
-            crate::spawn({
-                async move {
-                    send.send("two-part").await.unwrap();
-                    send.reset();
-                }
-            });
-
-            for _ in 0..5 {
-                tokio::task::yield_now().await;
-            }
-
-            {
-                let buf = vec![0u8; 4];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"two-");
-            }
-
-            {
-                let buf = vec![0u8; 4];
-                let (res, buf) = cr.read(buf).await;
-                let n = res.unwrap();
-                assert_eq!(&buf[..n], b"part");
-            }
-
-            {
-                let buf = vec![0u8; 0];
-                let (res, _) = cr.read(buf).await;
-                let err = res.unwrap_err();
-                assert_eq!(
-                    err.kind(),
-                    std::io::ErrorKind::ConnectionReset,
-                    "reached EOF"
-                );
-            }
-        })
-    }
-}
diff --git a/crates/fluke-buffet/src/io/non_uring.rs b/crates/fluke-buffet/src/io/non_uring.rs
index 995631a6..0c7cf38a 100644
--- a/crates/fluke-buffet/src/io/non_uring.rs
+++ b/crates/fluke-buffet/src/io/non_uring.rs
@@ -6,7 +6,7 @@ impl<T> ReadOwned for T
 where
     T: AsyncRead + Unpin,
 {
-    async fn read<B: IoBufMut>(&mut self, mut buf: B) -> BufResult<usize, B> {
+    async fn read_owned<B: IoBufMut>(&mut self, mut buf: B) -> BufResult<usize, B> {
         let buf_slice = unsafe { buf.slice_mut() };
         let res = tokio::io::AsyncReadExt::read(self, buf_slice).await;
         (res, buf)
@@ -17,7 +17,8 @@ impl<T> WriteOwned for T
 where
     T: AsyncWrite + Unpin,
 {
-    async fn write(&mut self, buf: Piece) -> BufResult<usize, Piece> {
+    async fn write_owned(&mut self, buf: impl Into<Piece>) -> BufResult<usize, Piece> {
+        let buf = buf.into();
         let res = tokio::io::AsyncWriteExt::write(self, &buf[..]).await;
         (res, buf)
     }
@@ -26,7 +27,7 @@ where
     // everything in `IoSlice`, advancing correctly, etc. It's not fun, but it
     // should yield a boost for non-uring codepaths.
 
-    async fn shutdown(&mut self, _how: std::net::Shutdown) -> std::io::Result<()> {
+    async fn shutdown(&mut self) -> std::io::Result<()> {
         AsyncWriteExt::shutdown(self).await
     }
 }
diff --git a/crates/fluke-buffet/src/io/pipe.rs b/crates/fluke-buffet/src/io/pipe.rs
new file mode 100644
index 00000000..2dc96645
--- /dev/null
+++ b/crates/fluke-buffet/src/io/pipe.rs
@@ -0,0 +1,233 @@
+use tokio::sync::mpsc;
+
+use crate::{Piece, ReadOwned, WriteOwned};
+
+/// Create a new pipe.
+pub fn pipe() -> (PipeWrite, PipeRead) {
+    let (tx, rx) = mpsc::channel(1);
+    (
+        PipeWrite { tx },
+        PipeRead {
+            rx,
+            state: Default::default(),
+            remain: None,
+        },
+    )
+}
+
+enum PipeEvent {
+    Piece(Piece),
+    Reset,
+    // close is just dropping the channel
+}
+
+#[derive(Clone, Copy, Default)]
+enum ReadState {
+    #[default]
+    Live,
+    Reset,
+    Eof,
+}
+
+pub struct PipeRead {
+    rx: mpsc::Receiver<PipeEvent>,
+    remain: Option<Piece>,
+    state: ReadState,
+}
+
+impl ReadOwned for PipeRead {
+    async fn read_owned<B: crate::IoBufMut>(&mut self, mut buf: B) -> crate::BufResult<usize, B> {
+        loop {
+            match self.state {
+                ReadState::Live => {
+                    // good, continue
+                }
+                ReadState::Reset => {
+                    let err = std::io::Error::new(
+                        std::io::ErrorKind::ConnectionReset,
+                        "simulated connection reset",
+                    );
+                    return (Err(err), buf);
+                }
+                ReadState::Eof => return (Ok(0), buf),
+            }
+
+            if self.remain.is_none() {
+                match self.rx.recv().await {
+                    Some(PipeEvent::Piece(piece)) => {
+                        assert!(!piece.is_empty());
+                        self.remain = Some(piece);
+                    }
+                    Some(PipeEvent::Reset) => {
+                        self.state = ReadState::Reset;
+                        continue;
+                    }
+                    None => {
+                        self.state = ReadState::Eof;
+                        continue;
+                    }
+                }
+            }
+
+            let remain = self.remain.take().unwrap();
+            let avail = buf.io_buf_mut_capacity();
+            let read_size = avail.min(remain.len());
+            let (copied, remain) = remain.split_at(read_size);
+            assert_eq!(copied.len(), read_size);
+            unsafe {
+                buf.slice_mut()[..read_size].copy_from_slice(&copied[..]);
+            }
+
+            if !remain.is_empty() {
+                self.remain = Some(remain);
+            }
+            return (Ok(read_size), buf);
+        }
+    }
+}
+
+pub struct PipeWrite {
+    tx: mpsc::Sender<PipeEvent>,
+}
+
+impl PipeWrite {
+    /// Simulate a connection reset
+    pub async fn reset(self) {
+        self.tx.send(PipeEvent::Reset).await.unwrap()
+    }
+}
+
+impl WriteOwned for PipeWrite {
+    async fn write_owned(&mut self, buf: impl Into<Piece>) -> crate::BufResult<usize, Piece> {
+        let buf = buf.into();
+        if buf.is_empty() {
+            // ignore 0-length writes
+        }
+
+        if self.tx.send(PipeEvent::Piece(buf.clone())).await.is_err() {
+            let err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "simulated broken pipe");
+            return (Err(err), buf);
+        }
+
+        (Ok(buf.len()), buf)
+    }
+
+    async fn shutdown(&mut self) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(all(test, not(feature = "miri")))]
+mod tests {
+    use crate::{ReadOwned, WriteOwned};
+
+    use super::pipe;
+    use std::{cell::RefCell, rc::Rc};
+
+    #[test]
+    fn test_pipe() {
+        crate::start(async move {
+            let (mut w, mut r) = pipe();
+            let wrote_three = Rc::new(RefCell::new(false));
+
+            crate::spawn({
+                let wrote_three = wrote_three.clone();
+                async move {
+                    w.write_all_owned("one").await.unwrap();
+                    w.write_all_owned("two").await.unwrap();
+                    w.write_all_owned("three").await.unwrap();
+                    *wrote_three.borrow_mut() = true;
+                    w.write_all_owned("splitread").await.unwrap();
+                }
+            });
+
+            {
+                let buf = vec![0u8; 256];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"one");
+            }
+
+            assert!(!*wrote_three.borrow());
+
+            {
+                let buf = vec![0u8; 256];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"two");
+            }
+
+            tokio::task::yield_now().await;
+            assert!(*wrote_three.borrow());
+
+            {
+                let buf = vec![0u8; 256];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"three");
+            }
+
+            {
+                let buf = vec![0u8; 5];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"split");
+
+                let buf = vec![0u8; 256];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"read");
+            }
+
+            {
+                let buf = vec![0u8; 0];
+                let (res, _) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(n, 0, "reached EOF");
+            }
+        })
+    }
+
+    #[test]
+    fn test_pipe_fragmented_read() {
+        crate::start(async move {
+            let (mut w, mut r) = pipe();
+
+            crate::spawn({
+                async move {
+                    w.write_all_owned("two-part").await.unwrap();
+                    w.reset().await;
+                }
+            });
+
+            for _ in 0..5 {
+                tokio::task::yield_now().await;
+            }
+
+            {
+                let buf = vec![0u8; 4];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"two-");
+            }
+
+            {
+                let buf = vec![0u8; 4];
+                let (res, buf) = r.read_owned(buf).await;
+                let n = res.unwrap();
+                assert_eq!(&buf[..n], b"part");
+            }
+
+            {
+                let buf = vec![0u8; 0];
+                let (res, _) = r.read_owned(buf).await;
+                let err = res.unwrap_err();
+                assert_eq!(
+                    err.kind(),
+                    std::io::ErrorKind::ConnectionReset,
+                    "reached EOF"
+                );
+            }
+        })
+    }
+}
diff --git a/crates/fluke-buffet/src/net/net_uring.rs b/crates/fluke-buffet/src/net/net_uring.rs
index 812cbddc..fd1ff05d 100644
--- a/crates/fluke-buffet/src/net/net_uring.rs
+++ b/crates/fluke-buffet/src/net/net_uring.rs
@@ -1,6 +1,6 @@
 use std::{
     mem::ManuallyDrop,
-    net::{Shutdown, SocketAddr},
+    net::SocketAddr,
     os::fd::{AsRawFd, FromRawFd, RawFd},
     rc::Rc,
 };
@@ -113,7 +113,7 @@ impl TcpListener {
 pub struct TcpReadHalf(Rc<TcpStream>);
 
 impl ReadOwned for TcpReadHalf {
-    async fn read<B: IoBufMut>(&mut self, mut buf: B) -> BufResult<usize, B> {
+    async fn read_owned<B: IoBufMut>(&mut self, mut buf: B) -> BufResult<usize, B> {
         let sqe = Read::new(
             io_uring::types::Fd(self.0.fd),
             buf.io_buf_mut_stable_mut_ptr(),
@@ -132,7 +132,8 @@ impl ReadOwned for TcpReadHalf {
 pub struct TcpWriteHalf(Rc<TcpStream>);
 
 impl WriteOwned for TcpWriteHalf {
-    async fn write(&mut self, buf: Piece) -> BufResult<usize, Piece> {
+    async fn write_owned(&mut self, buf: impl Into<Piece>) -> BufResult<usize, Piece> {
+        let buf = buf.into();
         let sqe = Write::new(
             io_uring::types::Fd(self.0.fd),
             buf.as_ref().as_ptr(),
@@ -149,16 +150,9 @@ impl WriteOwned for TcpWriteHalf {
 
     // TODO: implement writev
 
-    async fn shutdown(&mut self, how: Shutdown) -> std::io::Result<()> {
-        let sqe = io_uring::opcode::Shutdown::new(
-            io_uring::types::Fd(self.0.fd),
-            match how {
-                Shutdown::Read => libc::SHUT_RD,
-                Shutdown::Write => libc::SHUT_WR,
-                Shutdown::Both => libc::SHUT_RDWR,
-            },
-        )
-        .build();
+    async fn shutdown(&mut self) -> std::io::Result<()> {
+        let sqe =
+            io_uring::opcode::Shutdown::new(io_uring::types::Fd(self.0.fd), libc::SHUT_WR).build();
         let cqe = get_ring().push(sqe).await;
         cqe.error_for_errno()?;
         Ok(())
@@ -232,10 +226,10 @@ mod tests {
 
             let (mut r, mut w) = stream.into_halves();
             // write bye
-            w.write_all("howdy".into()).await?;
+            w.write_all_owned("howdy").await?;
 
             let buf = vec![0u8; 1024];
-            let (res, buf) = r.read(buf).await;
+            let (res, buf) = r.read_owned(buf).await;
             let n = res?;
             let slice = &buf[..n];
             println!(
diff --git a/crates/fluke-buffet/src/piece.rs b/crates/fluke-buffet/src/piece.rs
index 7b435cee..606dc7bb 100644
--- a/crates/fluke-buffet/src/piece.rs
+++ b/crates/fluke-buffet/src/piece.rs
@@ -74,6 +74,13 @@ impl From<Roll> for PieceCore {
     }
 }
 
+impl From<()> for PieceCore {
+    #[inline(always)]
+    fn from(_empty: ()) -> Self {
+        PieceCore::Static(&[])
+    }
+}
+
 impl From<PieceStr> for Piece {
     fn from(s: PieceStr) -> Self {
         s.piece
@@ -218,7 +225,18 @@ impl PieceList {
 
     /// Add a single chunk to the back of the list
     pub fn push_back(&mut self, chunk: impl Into<Piece>) {
-        self.pieces.push_back(chunk.into());
+        let chunk = chunk.into();
+        if !chunk.is_empty() {
+            self.pieces.push_back(chunk);
+        }
+    }
+
+    /// Add a single chunk to the front of the list
+    pub fn push_front(&mut self, chunk: impl Into<Piece>) {
+        let chunk = chunk.into();
+        if !chunk.is_empty() {
+            self.pieces.push_front(chunk);
+        }
     }
 
     /// Add a single chunk to the back list and return self
@@ -227,11 +245,6 @@ impl PieceList {
         self
     }
 
-    /// Add a single chunk to the front of the list
-    pub fn push_front(&mut self, chunk: impl Into<Piece>) {
-        self.pieces.push_front(chunk.into());
-    }
-
     /// Add a single chunk to the front of the list and return self
     pub fn preceded_by(mut self, chunk: impl Into<Piece>) -> Self {
         self.push_front(chunk);
diff --git a/crates/fluke-buffet/src/roll.rs b/crates/fluke-buffet/src/roll.rs
index a564bfa7..0acc7fe2 100644
--- a/crates/fluke-buffet/src/roll.rs
+++ b/crates/fluke-buffet/src/roll.rs
@@ -8,7 +8,7 @@ use std::{
     str::Utf8Error,
 };
 
-use crate::{io::ReadOwned, IoBufMut};
+use crate::{io::ReadOwned, Error, IoBufMut};
 use nom::{
     Compare, CompareResult, FindSubstring, InputIter, InputLength, InputTake, InputTakeAtPosition,
     Needed, Slice,
@@ -17,6 +17,8 @@ use tracing::trace;
 
 use crate::{Buf, BufMut, BUF_SIZE};
 
+type Result<T, E = crate::Error> = std::result::Result<T, E>;
+
 /// A "rolling buffer". Uses either one [BufMut] or a `Box<[u8]>` for storage.
 /// This buffer never grows, but it can be split, and it can be reallocated so
 /// it regains its initical capacity, minus the length of the filled part.
@@ -112,13 +114,9 @@ impl BoxStorage {
     }
 }
 
-#[derive(Debug, thiserror::Error)]
-#[error("slice does not fit into this RollMut")]
-pub struct DoesNotFit;
-
 impl RollMut {
     /// Allocate, using a single [BufMut] for storage.
-    pub fn alloc() -> eyre::Result<Self> {
+    pub fn alloc() -> Result<Self> {
         Ok(Self {
             storage: StorageMut::Buf(BufMut::alloc()?),
             len: 0,
@@ -149,7 +147,7 @@ impl RollMut {
     /// Reallocates the backing storage for this buffer, copying the filled
     /// portion into it. Panics if `len() == storage_size()`, in which case
     /// reallocating won't do much good
-    pub fn realloc(&mut self) -> eyre::Result<()> {
+    pub fn realloc(&mut self) -> Result<()> {
         assert!(self.len() != self.storage_size());
 
         let next_storage = match &self.storage {
@@ -185,7 +183,7 @@ impl RollMut {
     /// If this buffer's size matches the underlying storage size,
     /// this is equivalent to `grow`. Otherwise, it's equivalent
     /// to `realloc`.
-    pub fn reserve(&mut self) -> eyre::Result<()> {
+    pub fn reserve(&mut self) -> Result<()> {
         if self.len() < self.cap() {
             return Ok(());
         }
@@ -203,7 +201,7 @@ impl RollMut {
     }
 
     /// Make sure we can hold "request_len"
-    pub fn reserve_at_least(&mut self, requested_len: usize) -> Result<(), eyre::Error> {
+    pub fn reserve_at_least(&mut self, requested_len: usize) -> Result<()> {
         while self.cap() < requested_len {
             if self.cap() < self.storage_size() {
                 // we don't need to go up a buffer size
@@ -262,7 +260,7 @@ impl RollMut {
             off: read_off,
             cap: read_cap.try_into().unwrap(),
         };
-        let (res, mut read_into) = r.read(read_into).await;
+        let (res, mut read_into) = r.read_owned(read_into).await;
         if let Ok(n) = &res {
             tracing::trace!("read_into got {} bytes", *n);
             read_into.buf.len += *n as u32;
@@ -273,12 +271,12 @@ impl RollMut {
     }
 
     /// Put a slice into this buffer, fails if the slice doesn't fit in the buffer's capacity
-    pub fn put(&mut self, s: impl AsRef<[u8]>) -> Result<(), DoesNotFit> {
+    pub fn put(&mut self, s: impl AsRef<[u8]>) -> Result<()> {
         let s = s.as_ref();
 
         let len = s.len();
         if len > self.cap() {
-            return Err(DoesNotFit);
+            return Err(Error::DoesNotFit);
         }
         unsafe {
             let ptr = self.storage.as_mut_ptr().add(self.len as usize);
@@ -290,11 +288,7 @@ impl RollMut {
     }
 
     /// Put data into this RollMut with a closure. Panics if `len > self.cap()`
-    pub fn put_with<T, E>(
-        &mut self,
-        len: usize,
-        f: impl FnOnce(&mut [u8]) -> Result<T, E>,
-    ) -> Result<T, E> {
+    pub fn put_with<T>(&mut self, len: usize, f: impl FnOnce(&mut [u8]) -> Result<T>) -> Result<T> {
         assert!(len <= self.cap());
 
         let u32_len: u32 = len.try_into().unwrap();
@@ -314,8 +308,8 @@ impl RollMut {
     pub fn put_to_roll(
         &mut self,
         len: usize,
-        f: impl FnOnce(&mut [u8]) -> eyre::Result<()>,
-    ) -> eyre::Result<Roll> {
+        f: impl FnOnce(&mut [u8]) -> Result<()>,
+    ) -> Result<Roll> {
         // TODO: this whole dance is a bit silly: the idea is to have a
         // `RollMut` around that we use whenever we need to serialize something.
         // it's weird that we need to do all this for it to happen but ah well.
@@ -1063,14 +1057,14 @@ mod tests {
     #[test]
     #[cfg(not(feature = "miri"))]
     fn test_roll_readfrom_start() {
-        use crate::io::ChanRead;
+        use crate::WriteOwned;
 
         crate::start(async move {
             let mut rm = RollMut::alloc().unwrap();
 
-            let (send, mut read) = ChanRead::new();
+            let (mut send, mut read) = crate::pipe();
             crate::spawn(async move {
-                send.send("123456").await.unwrap();
+                send.write_all_owned("123456").await.unwrap();
             });
 
             let mut res;
@@ -1211,7 +1205,7 @@ mod tests {
             let send_fut = async move {
                 let stream = TcpStream::connect(local_addr).await?;
                 let (_stream_r, mut stream_w) = IntoHalves::into_halves(stream);
-                stream_w.write_all(roll.into()).await?;
+                stream_w.write_all_owned(roll).await?;
                 Ok::<_, eyre::Report>(())
             };
 
@@ -1222,7 +1216,7 @@ mod tests {
 
                 let mut buf = vec![0u8; 1024];
                 let res;
-                (res, buf) = stream_r.read(buf).await;
+                (res, buf) = stream_r.read_owned(buf).await;
                 let n = res?;
 
                 assert_eq!(&buf[..n], b"hello");
diff --git a/crates/fluke-h2-parse/Cargo.toml b/crates/fluke-h2-parse/Cargo.toml
new file mode 100644
index 00000000..2e648364
--- /dev/null
+++ b/crates/fluke-h2-parse/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "fluke-h2-parse"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+enum-repr = "0.2.6"
+enumflags2 = "0.7.9"
+nom = { version = "7.1.3", default-features = false }
+fluke-buffet = { version = "0.1.0", path = "../fluke-buffet" }
+thiserror = "1.0.60"
+byteorder = "1.5.0"
+tracing = "0.1.40"
diff --git a/crates/fluke/src/h2/parse.rs b/crates/fluke-h2-parse/src/lib.rs
similarity index 84%
rename from crates/fluke/src/h2/parse.rs
rename to crates/fluke-h2-parse/src/lib.rs
index 76b1dcfd..521501fb 100644
--- a/crates/fluke/src/h2/parse.rs
+++ b/crates/fluke-h2-parse/src/lib.rs
@@ -3,10 +3,16 @@
 //! HTTP/2 <https://httpwg.org/specs/rfc9113.html>
 //! HTTP semantics <https://httpwg.org/specs/rfc9110.html>
 
-use std::{fmt, ops::RangeInclusive};
+use std::{fmt, io::Write, ops::RangeInclusive};
 
+use byteorder::{BigEndian, WriteBytesExt};
 use enum_repr::EnumRepr;
+
+pub use enumflags2;
 use enumflags2::{bitflags, BitFlags};
+
+pub use nom;
+
 use nom::{
     combinator::map,
     number::streaming::{be_u16, be_u24, be_u32, be_u8},
@@ -14,7 +20,7 @@ use nom::{
     IResult,
 };
 
-use fluke_buffet::{Roll, RollMut};
+use fluke_buffet::{Piece, Roll, RollMut};
 
 /// This is sent by h2 clients after negotiating over ALPN, or when doing h2c.
 pub const PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
@@ -24,6 +30,10 @@ pub fn preface(i: Roll) -> IResult<Roll, ()> {
     Ok((i, ()))
 }
 
+pub trait IntoPiece {
+    fn into_piece(self, scratch: &mut RollMut) -> std::io::Result<Piece>;
+}
+
 /// See https://httpwg.org/specs/rfc9113.html#FrameTypes
 #[EnumRepr(type = "u8")]
 #[derive(Debug, Clone, Copy)]
@@ -56,6 +66,18 @@ pub enum FrameType {
     Unknown(EncodedFrameType),
 }
 
+impl FrameType {
+    /// Turn this [FrameType] into a [Frame]
+    pub fn into_frame(self, stream_id: StreamId) -> Frame {
+        Frame {
+            frame_type: self,
+            len: 0,
+            reserved: 0,
+            stream_id,
+        }
+    }
+}
+
 /// See https://httpwg.org/specs/rfc9113.html#DATA
 #[bitflags]
 #[repr(u8)]
@@ -169,7 +191,7 @@ impl FrameType {
 }
 
 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub struct StreamId(pub(crate) u32);
+pub struct StreamId(pub u32);
 
 impl StreamId {
     /// Stream ID used for connection control frames
@@ -328,22 +350,40 @@ impl Frame {
         Ok((i, frame))
     }
 
-    pub fn write_into(self, mut w: impl std::io::Write) -> eyre::Result<()> {
+    pub fn write_into(self, mut w: impl std::io::Write) -> std::io::Result<()> {
         use byteorder::{BigEndian, WriteBytesExt};
         w.write_u24::<BigEndian>(self.len as _)?;
         let ft = self.frame_type.encode();
         w.write_u8(ft.ty)?;
         w.write_u8(ft.flags)?;
-        // TODO: do we ever need to write the reserved bit?
-        w.write_u32::<BigEndian>(self.stream_id.0)?;
+        w.write_all(&pack_reserved_and_stream_id(self.reserved, self.stream_id))?;
 
         Ok(())
     }
 
-    pub fn into_roll(self, mut scratch: &mut RollMut) -> eyre::Result<Roll> {
+    /// 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,
+        }
+    }
+}
+
+impl IntoPiece for Frame {
+    fn into_piece(self, mut scratch: &mut RollMut) -> std::io::Result<Piece> {
         debug_assert_eq!(scratch.len(), 0);
         self.write_into(&mut scratch)?;
-        Ok(scratch.take_all())
+        Ok(scratch.take_all().into())
     }
 }
 
@@ -365,9 +405,18 @@ fn parse_reserved_and_stream_id(i: Roll) -> IResult<Roll, (u8, StreamId)> {
     parse_reserved_and_u31(i).map(|(i, (reserved, stream_id))| (i, (reserved, StreamId(stream_id))))
 }
 
+/// Pack `reserved` into the first bit of a u32 and write it out as big-endian
+fn pack_reserved_and_stream_id(reserved: u8, stream_id: StreamId) -> [u8; 4] {
+    let mut bytes = stream_id.0.to_be_bytes();
+    if reserved != 0 {
+        bytes[0] |= 0b1000_0000;
+    }
+    bytes
+}
+
 // cf. https://httpwg.org/specs/rfc9113.html#HEADERS
 #[derive(Debug)]
-pub(crate) struct PrioritySpec {
+pub struct PrioritySpec {
     pub exclusive: bool,
     pub stream_dependency: StreamId,
     // 0-255 => 1-256
@@ -375,7 +424,7 @@ pub(crate) struct PrioritySpec {
 }
 
 impl PrioritySpec {
-    pub(crate) fn parse(i: Roll) -> IResult<Roll, Self> {
+    pub fn parse(i: Roll) -> IResult<Roll, Self> {
         map(
             tuple((parse_reserved_and_stream_id, be_u8)),
             |((exclusive, stream_dependency), weight)| Self {
@@ -390,6 +439,13 @@ impl PrioritySpec {
 #[derive(Clone, Copy)]
 pub struct ErrorCode(u32);
 
+impl ErrorCode {
+    /// Returns the underlying u32
+    pub fn as_repr(self) -> u32 {
+        self.0
+    }
+}
+
 impl fmt::Debug for ErrorCode {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match KnownErrorCode::from_repr(self.0) {
@@ -669,7 +725,7 @@ impl Settings {
 
     /// Encode these settings into (u16, u32) pairs as specified in
     /// https://httpwg.org/specs/rfc9113.html#SETTINGS
-    pub fn write_into(self, mut w: impl std::io::Write) -> eyre::Result<()> {
+    pub fn write_into(self, mut w: impl std::io::Write) -> std::io::Result<()> {
         use byteorder::{BigEndian, WriteBytesExt};
 
         for (id, value) in self.pairs() {
@@ -679,11 +735,88 @@ impl Settings {
 
         Ok(())
     }
+}
 
-    /// Same as `write_into` but uses a scratch [RollMut] to return a [Roll]
-    pub fn into_roll(self, mut scratch: &mut RollMut) -> eyre::Result<Roll> {
+impl IntoPiece for Settings {
+    fn into_piece(self, mut scratch: &mut RollMut) -> std::io::Result<Piece> {
         debug_assert_eq!(scratch.len(), 0);
         self.write_into(&mut scratch)?;
-        Ok(scratch.take_all())
+        Ok(scratch.take_all().into())
+    }
+}
+
+/// Payload for a GOAWAY frame
+pub struct GoAway {
+    pub last_stream_id: StreamId,
+    pub error_code: ErrorCode,
+    pub additional_debug_data: Piece,
+}
+
+impl IntoPiece for GoAway {
+    fn into_piece(self, scratch: &mut RollMut) -> std::io::Result<Piece> {
+        let roll = scratch
+            .put_to_roll(8 + self.additional_debug_data.len(), |mut slice| {
+                slice.write_u32::<BigEndian>(self.last_stream_id.0)?;
+                slice.write_u32::<BigEndian>(self.error_code.0)?;
+                slice.write_all(&self.additional_debug_data[..])?;
+
+                Ok(())
+            })
+            .unwrap();
+        Ok(roll.into())
+    }
+}
+
+impl GoAway {
+    pub fn parse(i: Roll) -> IResult<Roll, Self> {
+        let (rest, (last_stream_id, error_code)) = tuple((be_u32, be_u32))(i)?;
+
+        let i = Roll::empty();
+        Ok((
+            i,
+            Self {
+                last_stream_id: StreamId(last_stream_id),
+                error_code: ErrorCode(error_code),
+                additional_debug_data: rest.into(),
+            },
+        ))
+    }
+}
+
+/// Payload for a RST_STREAM frame
+pub struct RstStream {
+    pub error_code: ErrorCode,
+}
+
+impl IntoPiece for RstStream {
+    fn into_piece(self, scratch: &mut RollMut) -> std::io::Result<Piece> {
+        let roll = scratch
+            .put_to_roll(4, |mut slice| {
+                slice.write_u32::<BigEndian>(self.error_code.0)?;
+                Ok(())
+            })
+            .unwrap();
+        Ok(roll.into())
+    }
+}
+
+impl RstStream {
+    pub fn parse(i: Roll) -> IResult<Roll, Self> {
+        let (rest, error_code) = be_u32(i)?;
+        Ok((
+            rest,
+            Self {
+                error_code: ErrorCode(error_code),
+            },
+        ))
+    }
+}
+
+impl<T> IntoPiece for T
+where
+    Piece: From<T>,
+{
+    fn into_piece(self, _scratch: &mut RollMut) -> std::io::Result<Piece> {
+        Ok(self.into())
     }
 }
diff --git a/crates/fluke-h2spec/.gitignore b/crates/fluke-h2spec/.gitignore
deleted file mode 100644
index e3e65ee2..00000000
--- a/crates/fluke-h2spec/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/target
-target
diff --git a/crates/fluke-h2spec/Cargo.lock b/crates/fluke-h2spec/Cargo.lock
deleted file mode 100644
index 8ad1cf9c..00000000
--- a/crates/fluke-h2spec/Cargo.lock
+++ /dev/null
@@ -1,1019 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 3
-
-[[package]]
-name = "addr2line"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
-dependencies = [
- "gimli",
-]
-
-[[package]]
-name = "adler"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
-
-[[package]]
-name = "aho-corasick"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "autocfg"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
-
-[[package]]
-name = "backtrace"
-version = "0.3.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
-dependencies = [
- "addr2line",
- "cc",
- "cfg-if",
- "libc",
- "miniz_oxide",
- "object",
- "rustc-demangle",
-]
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
-[[package]]
-name = "bitflags"
-version = "2.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
-
-[[package]]
-name = "bytemuck"
-version = "1.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15"
-
-[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
-name = "bytes"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
-
-[[package]]
-name = "cc"
-version = "1.0.90"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5"
-
-[[package]]
-name = "cfg-if"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-
-[[package]]
-name = "color-eyre"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204"
-dependencies = [
- "backtrace",
- "color-spantrace",
- "eyre",
- "indenter",
- "once_cell",
- "owo-colors",
- "tracing-error",
-]
-
-[[package]]
-name = "color-spantrace"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
-dependencies = [
- "once_cell",
- "owo-colors",
- "tracing-core",
- "tracing-error",
-]
-
-[[package]]
-name = "either"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
-
-[[package]]
-name = "enum-repr"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad30c9c0fa1aaf1ae5010dab11f1117b15d35faf62cda4bbbc53b9987950f18"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "enumflags2"
-version = "0.7.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3278c9d5fb675e0a51dabcf4c0d355f692b064171535ba72361be1528a9d8e8d"
-dependencies = [
- "enumflags2_derive",
-]
-
-[[package]]
-name = "enumflags2_derive"
-version = "0.7.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c785274071b1b420972453b306eeca06acf4633829db4223b58a2a8c5953bc4"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.52",
-]
-
-[[package]]
-name = "errno"
-version = "0.3.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "eyre"
-version = "0.6.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
-dependencies = [
- "indenter",
- "once_cell",
-]
-
-[[package]]
-name = "fluke"
-version = "0.1.0"
-dependencies = [
- "byteorder",
- "enum-repr",
- "enumflags2",
- "eyre",
- "fluke-buffet",
- "fluke-hpack",
- "fluke-maybe-uring",
- "futures-util",
- "http",
- "memchr",
- "nom",
- "pretty-hex",
- "smallvec",
- "thiserror",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "fluke-buffet"
-version = "0.1.0"
-dependencies = [
- "eyre",
- "fluke-maybe-uring",
- "http",
- "memchr",
- "memmap2",
- "nom",
- "pretty-hex",
- "thiserror",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "fluke-h2spec"
-version = "0.1.0"
-dependencies = [
- "color-eyre",
- "fluke",
- "tokio",
- "tracing",
- "tracing-subscriber",
- "which",
-]
-
-[[package]]
-name = "fluke-hpack"
-version = "0.3.0"
-dependencies = [
- "tracing",
-]
-
-[[package]]
-name = "fluke-maybe-uring"
-version = "0.1.1"
-dependencies = [
- "bytemuck",
- "tokio",
- "tokio-uring",
-]
-
-[[package]]
-name = "fnv"
-version = "1.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
-
-[[package]]
-name = "futures-core"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
-
-[[package]]
-name = "futures-macro"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.52",
-]
-
-[[package]]
-name = "futures-task"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
-
-[[package]]
-name = "futures-util"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
-dependencies = [
- "futures-core",
- "futures-macro",
- "futures-task",
- "pin-project-lite",
- "pin-utils",
- "slab",
-]
-
-[[package]]
-name = "gimli"
-version = "0.28.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
-
-[[package]]
-name = "hermit-abi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
-
-[[package]]
-name = "home"
-version = "0.5.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
-dependencies = [
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "http"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
-dependencies = [
- "bytes",
- "fnv",
- "itoa",
-]
-
-[[package]]
-name = "indenter"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
-
-[[package]]
-name = "io-uring"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9febecd4aebbe9c7c23c8e536e966805fdf09944c8a915e7991ee51acb67087"
-dependencies = [
- "bitflags 1.3.2",
- "libc",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
-
-[[package]]
-name = "lazy_static"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
-
-[[package]]
-name = "libc"
-version = "0.2.153"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.4.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
-
-[[package]]
-name = "lock_api"
-version = "0.4.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
-dependencies = [
- "autocfg",
- "scopeguard",
-]
-
-[[package]]
-name = "log"
-version = "0.4.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
-
-[[package]]
-name = "matchers"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
-dependencies = [
- "regex-automata 0.1.10",
-]
-
-[[package]]
-name = "memchr"
-version = "2.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
-
-[[package]]
-name = "memmap2"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
-name = "miniz_oxide"
-version = "0.7.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
-dependencies = [
- "adler",
-]
-
-[[package]]
-name = "mio"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
-dependencies = [
- "libc",
- "wasi",
- "windows-sys 0.48.0",
-]
-
-[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
-name = "nu-ansi-term"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
-dependencies = [
- "overload",
- "winapi",
-]
-
-[[package]]
-name = "num_cpus"
-version = "1.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
-dependencies = [
- "hermit-abi",
- "libc",
-]
-
-[[package]]
-name = "object"
-version = "0.32.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
-
-[[package]]
-name = "overload"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
-
-[[package]]
-name = "owo-colors"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
-
-[[package]]
-name = "parking_lot"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
-dependencies = [
- "lock_api",
- "parking_lot_core",
-]
-
-[[package]]
-name = "parking_lot_core"
-version = "0.9.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
-dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-targets 0.48.5",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "pretty-hex"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbc83ee4a840062f368f9096d80077a9841ec117e17e7f700df81958f1451254"
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.78"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
-dependencies = [
- "bitflags 1.3.2",
-]
-
-[[package]]
-name = "regex"
-version = "1.10.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata 0.4.6",
- "regex-syntax 0.8.2",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
-dependencies = [
- "regex-syntax 0.6.29",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax 0.8.2",
-]
-
-[[package]]
-name = "regex-syntax"
-version = "0.6.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
-
-[[package]]
-name = "regex-syntax"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
-
-[[package]]
-name = "rustc-demangle"
-version = "0.1.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
-
-[[package]]
-name = "rustix"
-version = "0.38.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
-dependencies = [
- "bitflags 2.4.2",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-[[package]]
-name = "sharded-slab"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
-name = "signal-hook-registry"
-version = "1.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "slab"
-version = "0.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "smallvec"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
-
-[[package]]
-name = "socket2"
-version = "0.4.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d"
-dependencies = [
- "libc",
- "winapi",
-]
-
-[[package]]
-name = "socket2"
-version = "0.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.52"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "thiserror"
-version = "1.0.58"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
-dependencies = [
- "thiserror-impl",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.58"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.52",
-]
-
-[[package]]
-name = "thread_local"
-version = "1.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
-dependencies = [
- "cfg-if",
- "once_cell",
-]
-
-[[package]]
-name = "tokio"
-version = "1.36.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
-dependencies = [
- "backtrace",
- "bytes",
- "libc",
- "mio",
- "num_cpus",
- "parking_lot",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2 0.5.6",
- "tokio-macros",
- "windows-sys 0.48.0",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.52",
-]
-
-[[package]]
-name = "tokio-uring"
-version = "0.4.0"
-source = "git+https://github.com/tokio-rs/tokio-uring?rev=a69d4bf57776a085a6516f4c022e2bf5d1814762#a69d4bf57776a085a6516f4c022e2bf5d1814762"
-dependencies = [
- "futures-util",
- "io-uring",
- "libc",
- "slab",
- "socket2 0.4.10",
- "tokio",
-]
-
-[[package]]
-name = "tracing"
-version = "0.1.40"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
-dependencies = [
- "pin-project-lite",
- "tracing-attributes",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-attributes"
-version = "0.1.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.52",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
-dependencies = [
- "once_cell",
- "valuable",
-]
-
-[[package]]
-name = "tracing-error"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
-dependencies = [
- "tracing",
- "tracing-subscriber",
-]
-
-[[package]]
-name = "tracing-log"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
-dependencies = [
- "log",
- "once_cell",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-subscriber"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
-dependencies = [
- "matchers",
- "nu-ansi-term",
- "once_cell",
- "regex",
- "sharded-slab",
- "smallvec",
- "thread_local",
- "tracing",
- "tracing-core",
- "tracing-log",
-]
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
-
-[[package]]
-name = "valuable"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
-
-[[package]]
-name = "wasi"
-version = "0.11.0+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
-
-[[package]]
-name = "which"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fa5e0c10bf77f44aac573e498d1a82d5fbd5e91f6fc0a99e7be4b38e85e101c"
-dependencies = [
- "either",
- "home",
- "once_cell",
- "rustix",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[package]]
-name = "windows-sys"
-version = "0.48.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
-dependencies = [
- "windows-targets 0.48.5",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
-dependencies = [
- "windows-targets 0.52.4",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
-dependencies = [
- "windows_aarch64_gnullvm 0.48.5",
- "windows_aarch64_msvc 0.48.5",
- "windows_i686_gnu 0.48.5",
- "windows_i686_msvc 0.48.5",
- "windows_x86_64_gnu 0.48.5",
- "windows_x86_64_gnullvm 0.48.5",
- "windows_x86_64_msvc 0.48.5",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
-dependencies = [
- "windows_aarch64_gnullvm 0.52.4",
- "windows_aarch64_msvc 0.52.4",
- "windows_i686_gnu 0.52.4",
- "windows_i686_msvc 0.52.4",
- "windows_x86_64_gnu 0.52.4",
- "windows_x86_64_gnullvm 0.52.4",
- "windows_x86_64_msvc 0.52.4",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
diff --git a/crates/fluke-h2spec/Cargo.toml b/crates/fluke-h2spec/Cargo.toml
deleted file mode 100644
index bd963ca9..00000000
--- a/crates/fluke-h2spec/Cargo.toml
+++ /dev/null
@@ -1,19 +0,0 @@
-[package]
-name = "fluke-h2spec"
-version = "0.1.0"
-edition = "2021"
-publish = false
-
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
-[dependencies]
-fluke = { version = "0.1.0", path = "../../crates/fluke" }
-color-eyre = "0.6.2"
-tokio = { version = "1.36.0", features = ["full"] }
-tracing = "0.1.40"
-tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
-which = "6.0.0"
-
-[features]
-default = ["uring"]
-uring = ["fluke/uring"]
diff --git a/crates/fluke-h2spec/src/main.rs b/crates/fluke-h2spec/src/main.rs
deleted file mode 100644
index 7fbef594..00000000
--- a/crates/fluke-h2spec/src/main.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-use std::{collections::VecDeque, path::PathBuf};
-
-use fluke::{
-    buffet::{net::TcpListener, IntoHalves, RollMut},
-    http::{StatusCode, Version},
-    Body, BodyChunk, Encoder, ExpectResponseHeaders, Headers, Request, Responder, Response,
-    ResponseDone, ServerDriver,
-};
-use tokio::process::Command;
-use tracing::{error, info};
-use tracing_subscriber::EnvFilter;
-
-use std::{net::SocketAddr, rc::Rc};
-
-fn main() {
-    color_eyre::install().unwrap();
-    tracing_subscriber::fmt()
-        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|e| {
-            eprintln!("Couldn't parse RUST_LOG: {e}");
-            EnvFilter::try_new("info").unwrap()
-        }))
-        // .without_time()
-        .init();
-
-    let h2spec_binary = match which::which("h2spec") {
-        Ok(h2spec_binary) => {
-            info!("Using h2spec binary from {}", h2spec_binary.display());
-            h2spec_binary
-        }
-        Err(_) => {
-            error!("Couldn't find h2spec binary in PATH, see https://github.com/summerwind/h2spec");
-            std::process::exit(1);
-        }
-    };
-
-    fluke::buffet::start(async move { real_main(h2spec_binary).await.unwrap() });
-}
-
-struct SDriver;
-
-impl ServerDriver for SDriver {
-    async fn handle<E: Encoder>(
-        &self,
-        req: Request,
-        req_body: &mut impl Body,
-        respond: Responder<E, ExpectResponseHeaders>,
-    ) -> color_eyre::Result<Responder<E, ResponseDone>> {
-        tracing::debug!(
-            "Handling {:?} {}, content_len = {:?}",
-            req.method,
-            req.uri,
-            req_body.content_len()
-        );
-
-        // read the entire body first
-        loop {
-            match req_body.next_chunk().await? {
-                BodyChunk::Chunk(chunk) => {
-                    tracing::debug!(len = %chunk.len(), "Got chunk")
-                }
-                BodyChunk::Done { trailers } => {
-                    if let Some(trailers) = trailers {
-                        tracing::debug!(num_trailers = trailers.len(), "Got trailers");
-                    }
-                    break;
-                }
-            }
-        }
-
-        // some h2spec test cases (e.g. 5.1.2/1) fail because we're too
-        // fast at responding to their requests, so they can't successfully
-        // trigger our "too many concurrent streams" condition.
-        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
-
-        tracing::debug!("Done reading body, sending response!");
-        let res = Response {
-            version: Version::HTTP_2,
-            status: StatusCode::OK,
-            headers: Headers::default(),
-        };
-        let mut body = TestBody::default();
-        respond.write_final_response_with_body(res, &mut body).await
-    }
-}
-
-#[derive(Default, Debug)]
-struct TestBody {
-    eof: bool,
-}
-
-impl TestBody {
-    const CONTENTS: &'static str = "I am a test body";
-}
-
-impl Body for TestBody {
-    fn content_len(&self) -> Option<u64> {
-        Some(Self::CONTENTS.len() as _)
-    }
-
-    fn eof(&self) -> bool {
-        self.eof
-    }
-
-    async fn next_chunk(&mut self) -> color_eyre::eyre::Result<BodyChunk> {
-        if self.eof {
-            Ok(BodyChunk::Done { trailers: None })
-        } else {
-            self.eof = true;
-            Ok(BodyChunk::Chunk(Self::CONTENTS.as_bytes().into()))
-        }
-    }
-}
-
-struct NotifyOnDrop(Rc<tokio::sync::Notify>);
-
-impl Drop for NotifyOnDrop {
-    fn drop(&mut self) {
-        self.0.notify_waiters();
-    }
-}
-
-async fn real_main(h2spec_binary: PathBuf) -> color_eyre::Result<()> {
-    let shutdown_notif = Rc::new(tokio::sync::Notify::new());
-    let addr = spawn_server("127.0.0.1:0".parse()?, shutdown_notif.clone()).await?;
-    let _shutdown_token = NotifyOnDrop(shutdown_notif);
-
-    let mut args = std::env::args().skip(1).collect::<VecDeque<_>>();
-    if matches!(args.front().map(|s| s.as_str()), Some("--")) {
-        args.pop_front();
-    }
-    tracing::info!("Custom args: {args:?}");
-
-    Command::new(h2spec_binary)
-        .arg("-p")
-        .arg(&format!("{}", addr.port()))
-        .arg("-o")
-        .arg("1")
-        .args(&args)
-        .spawn()?
-        .wait()
-        .await?;
-
-    Ok(())
-}
-
-pub(crate) async fn spawn_server(
-    addr: SocketAddr,
-    shutdown_notif: Rc<tokio::sync::Notify>,
-) -> color_eyre::Result<SocketAddr> {
-    let ln = TcpListener::bind(addr).await?;
-    let addr = ln.local_addr()?;
-    tracing::info!("Listening on {}", ln.local_addr()?);
-
-    let shutdown_notif_inner = shutdown_notif.clone();
-    let _task = tokio::task::spawn_local(async move {
-        let run_server = run_server(ln, shutdown_notif_inner);
-        tokio::select! {
-            res = run_server => {
-                res.unwrap()
-            },
-            _ = shutdown_notif.notified() => {
-                tracing::info!("Server shutting down gracefully")
-            }
-        }
-    });
-
-    Ok(addr)
-}
-
-pub(crate) async fn run_server(
-    ln: TcpListener,
-    shutdown_notif: Rc<tokio::sync::Notify>,
-) -> color_eyre::Result<()> {
-    loop {
-        let (stream, addr) = ln.accept().await?;
-        tracing::debug!(%addr, "Accepted connection from");
-        let conf = Rc::new(fluke::h2::ServerConf::default());
-        let client_buf = RollMut::alloc()?;
-        let driver = Rc::new(SDriver);
-
-        let handle_client = async move {
-            if let Err(e) = fluke::h2::serve(stream.into_halves(), conf, client_buf, driver).await {
-                tracing::debug!("error serving client {addr}: {e}, {e:?}");
-            }
-            tracing::debug!("done serving client {addr}");
-        };
-
-        let shutdown_notif_inner = shutdown_notif.clone();
-        tokio::task::spawn_local(async move {
-            tokio::select! {
-                res = handle_client => res,
-                _ = shutdown_notif_inner.notified() => {
-                    tracing::info!("Shutting down server");
-                }
-            }
-        });
-    }
-}
diff --git a/crates/fluke-sample-h2-server/.gitignore b/crates/fluke-sample-h2-server/.gitignore
deleted file mode 100644
index eb5a316c..00000000
--- a/crates/fluke-sample-h2-server/.gitignore
+++ /dev/null
@@ -1,1 +0,0 @@
-target
diff --git a/crates/fluke-sample-h2-server/Cargo.lock b/crates/fluke-sample-h2-server/Cargo.lock
deleted file mode 100644
index 9ab711fb..00000000
--- a/crates/fluke-sample-h2-server/Cargo.lock
+++ /dev/null
@@ -1,835 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 3
-
-[[package]]
-name = "addr2line"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
-dependencies = [
- "gimli",
-]
-
-[[package]]
-name = "adler"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
-
-[[package]]
-name = "autocfg"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
-
-[[package]]
-name = "backtrace"
-version = "0.3.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
-dependencies = [
- "addr2line",
- "cc",
- "cfg-if",
- "libc",
- "miniz_oxide",
- "object",
- "rustc-demangle",
-]
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
-[[package]]
-name = "bytes"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
-
-[[package]]
-name = "cc"
-version = "1.0.90"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5"
-
-[[package]]
-name = "cfg-if"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-
-[[package]]
-name = "color-eyre"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a667583cca8c4f8436db8de46ea8233c42a7d9ae424a82d338f2e4675229204"
-dependencies = [
- "backtrace",
- "color-spantrace",
- "eyre",
- "indenter",
- "once_cell",
- "owo-colors",
- "tracing-error",
-]
-
-[[package]]
-name = "color-spantrace"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
-dependencies = [
- "once_cell",
- "owo-colors",
- "tracing-core",
- "tracing-error",
-]
-
-[[package]]
-name = "equivalent"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
-
-[[package]]
-name = "eyre"
-version = "0.6.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
-dependencies = [
- "indenter",
- "once_cell",
-]
-
-[[package]]
-name = "fluke-sample-h2-server"
-version = "0.1.0"
-dependencies = [
- "bytes",
- "color-eyre",
- "hyper",
- "tokio",
- "tokio-stream",
- "tracing",
- "tracing-subscriber",
-]
-
-[[package]]
-name = "fnv"
-version = "1.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
-
-[[package]]
-name = "futures-channel"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
-dependencies = [
- "futures-core",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
-
-[[package]]
-name = "futures-sink"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
-
-[[package]]
-name = "futures-task"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
-
-[[package]]
-name = "futures-util"
-version = "0.3.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
-dependencies = [
- "futures-core",
- "futures-task",
- "pin-project-lite",
- "pin-utils",
-]
-
-[[package]]
-name = "gimli"
-version = "0.28.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
-
-[[package]]
-name = "h2"
-version = "0.3.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
-dependencies = [
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "futures-util",
- "http",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.14.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
-
-[[package]]
-name = "hermit-abi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
-
-[[package]]
-name = "http"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
-dependencies = [
- "bytes",
- "fnv",
- "itoa",
-]
-
-[[package]]
-name = "http-body"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
-dependencies = [
- "bytes",
- "http",
- "pin-project-lite",
-]
-
-[[package]]
-name = "httparse"
-version = "1.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
-
-[[package]]
-name = "httpdate"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
-
-[[package]]
-name = "hyper"
-version = "0.14.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80"
-dependencies = [
- "bytes",
- "futures-channel",
- "futures-core",
- "futures-util",
- "h2",
- "http",
- "http-body",
- "httparse",
- "httpdate",
- "itoa",
- "pin-project-lite",
- "socket2",
- "tokio",
- "tower-service",
- "tracing",
- "want",
-]
-
-[[package]]
-name = "indenter"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
-
-[[package]]
-name = "indexmap"
-version = "2.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b0b929d511467233429c45a44ac1dcaa21ba0f5ba11e4879e6ed28ddb4f9df4"
-dependencies = [
- "equivalent",
- "hashbrown",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
-
-[[package]]
-name = "lazy_static"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
-
-[[package]]
-name = "libc"
-version = "0.2.153"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
-
-[[package]]
-name = "lock_api"
-version = "0.4.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
-dependencies = [
- "autocfg",
- "scopeguard",
-]
-
-[[package]]
-name = "log"
-version = "0.4.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
-
-[[package]]
-name = "memchr"
-version = "2.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
-
-[[package]]
-name = "miniz_oxide"
-version = "0.7.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
-dependencies = [
- "adler",
-]
-
-[[package]]
-name = "mio"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
-dependencies = [
- "libc",
- "wasi",
- "windows-sys 0.48.0",
-]
-
-[[package]]
-name = "nu-ansi-term"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
-dependencies = [
- "overload",
- "winapi",
-]
-
-[[package]]
-name = "num_cpus"
-version = "1.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
-dependencies = [
- "hermit-abi",
- "libc",
-]
-
-[[package]]
-name = "object"
-version = "0.32.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
-
-[[package]]
-name = "overload"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
-
-[[package]]
-name = "owo-colors"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
-
-[[package]]
-name = "parking_lot"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
-dependencies = [
- "lock_api",
- "parking_lot_core",
-]
-
-[[package]]
-name = "parking_lot_core"
-version = "0.9.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
-dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-targets 0.48.5",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.78"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
-dependencies = [
- "bitflags",
-]
-
-[[package]]
-name = "rustc-demangle"
-version = "0.1.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-[[package]]
-name = "sharded-slab"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
-name = "signal-hook-registry"
-version = "1.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "slab"
-version = "0.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "smallvec"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
-
-[[package]]
-name = "socket2"
-version = "0.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871"
-dependencies = [
- "libc",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.52"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "thread_local"
-version = "1.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
-dependencies = [
- "cfg-if",
- "once_cell",
-]
-
-[[package]]
-name = "tokio"
-version = "1.36.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
-dependencies = [
- "backtrace",
- "bytes",
- "libc",
- "mio",
- "num_cpus",
- "parking_lot",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys 0.48.0",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tokio-stream"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
-dependencies = [
- "futures-core",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
-name = "tokio-util"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-sink",
- "pin-project-lite",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "tower-service"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
-
-[[package]]
-name = "tracing"
-version = "0.1.40"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
-dependencies = [
- "pin-project-lite",
- "tracing-attributes",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-attributes"
-version = "0.1.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
-dependencies = [
- "once_cell",
- "valuable",
-]
-
-[[package]]
-name = "tracing-error"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
-dependencies = [
- "tracing",
- "tracing-subscriber",
-]
-
-[[package]]
-name = "tracing-log"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
-dependencies = [
- "log",
- "once_cell",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-subscriber"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
-dependencies = [
- "nu-ansi-term",
- "sharded-slab",
- "smallvec",
- "thread_local",
- "tracing-core",
- "tracing-log",
-]
-
-[[package]]
-name = "try-lock"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
-
-[[package]]
-name = "valuable"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
-
-[[package]]
-name = "want"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
-dependencies = [
- "try-lock",
-]
-
-[[package]]
-name = "wasi"
-version = "0.11.0+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
-
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[package]]
-name = "windows-sys"
-version = "0.48.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
-dependencies = [
- "windows-targets 0.48.5",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
-dependencies = [
- "windows-targets 0.52.4",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
-dependencies = [
- "windows_aarch64_gnullvm 0.48.5",
- "windows_aarch64_msvc 0.48.5",
- "windows_i686_gnu 0.48.5",
- "windows_i686_msvc 0.48.5",
- "windows_x86_64_gnu 0.48.5",
- "windows_x86_64_gnullvm 0.48.5",
- "windows_x86_64_msvc 0.48.5",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
-dependencies = [
- "windows_aarch64_gnullvm 0.52.4",
- "windows_aarch64_msvc 0.52.4",
- "windows_i686_gnu 0.52.4",
- "windows_i686_msvc 0.52.4",
- "windows_x86_64_gnu 0.52.4",
- "windows_x86_64_gnullvm 0.52.4",
- "windows_x86_64_msvc 0.52.4",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.48.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
diff --git a/crates/fluke-sample-h2-server/Cargo.toml b/crates/fluke-sample-h2-server/Cargo.toml
deleted file mode 100644
index fd60ea19..00000000
--- a/crates/fluke-sample-h2-server/Cargo.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-[package]
-name = "fluke-sample-h2-server"
-version = "0.1.0"
-edition = "2021"
-publish = false
-
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
-[dependencies]
-bytes = "1.5.0"
-color-eyre = "0.6.2"
-hyper = { version = "0.14.28", features = ["server", "http2", "tcp", "stream"] }
-tokio = { version = "1.36.0", features = ["full"] }
-tokio-stream = "0.1.14"
-tracing = "0.1.40"
-tracing-subscriber = "0.3.18"
diff --git a/crates/fluke-sample-h2-server/src/main.rs b/crates/fluke-sample-h2-server/src/main.rs
deleted file mode 100644
index eccd2518..00000000
--- a/crates/fluke-sample-h2-server/src/main.rs
+++ /dev/null
@@ -1,93 +0,0 @@
-use std::{convert::Infallible, future::Future, pin::Pin};
-
-use bytes::Bytes;
-use hyper::{
-    service::{make_service_fn, Service},
-    Body, Request, Response,
-};
-use tokio_stream::{wrappers::ReceiverStream, StreamExt};
-use tracing::debug;
-
-struct TestService;
-
-impl Service<Request<Body>> for TestService {
-    type Response = Response<Body>;
-    type Error = Infallible;
-    type Future =
-        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
-
-    fn poll_ready(
-        &mut self,
-        _cx: &mut std::task::Context<'_>,
-    ) -> std::task::Poll<Result<(), Self::Error>> {
-        Ok(()).into()
-    }
-
-    fn call(&mut self, req: Request<Body>) -> Self::Future {
-        Box::pin(async move {
-            let (parts, body) = req.into_parts();
-            println!("Handling {parts:?}");
-
-            let path = parts.uri.path();
-            match path {
-                "/echo-body" => {
-                    let res = Response::builder().body(body).unwrap();
-                    Ok(res)
-                }
-                "/stream-big-body" => {
-                    let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(1);
-                    let rx = ReceiverStream::new(rx).map(Ok::<_, Infallible>);
-
-                    tokio::spawn(async move {
-                        let chunk = "this is a big chunk".repeat(256);
-                        let chunk = Bytes::from(chunk);
-                        for _ in 0..128 {
-                            let _ = tx.send(chunk.clone()).await;
-                        }
-                    });
-
-                    let res = Response::builder().body(Body::wrap_stream(rx)).unwrap();
-                    Ok(res)
-                }
-                _ => {
-                    let parts = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
-                    if let ["status", code] = parts.as_slice() {
-                        let code = code.parse::<u16>().unwrap();
-                        let res = Response::builder()
-                            .status(code)
-                            .body(Body::empty())
-                            .unwrap();
-                        debug!("Replying with {res:?}");
-                        Ok(res)
-                    } else {
-                        let res = Response::builder().status(404).body(Body::empty()).unwrap();
-                        Ok(res)
-                    }
-                }
-            }
-        })
-    }
-}
-
-#[tokio::main]
-async fn main() -> color_eyre::Result<()> {
-    color_eyre::install()?;
-    tracing_subscriber::fmt::init();
-
-    let listen_port: u16 = std::env::var("LISTEN_PORT")
-        .map(|s| s.parse().unwrap())
-        .unwrap_or_default();
-
-    let upstream = hyper::Server::bind(&format!("127.0.0.1:{listen_port}").parse()?)
-        .http2_only(true)
-        .serve(make_service_fn(|_addr| async move {
-            Ok::<_, Infallible>(TestService)
-        }));
-
-    let upstream_addr = upstream.local_addr();
-    println!("I listen on {upstream_addr}");
-
-    upstream.await?;
-
-    Ok(())
-}
diff --git a/crates/fluke/Cargo.toml b/crates/fluke/Cargo.toml
index a0bc72f4..2ab0314b 100644
--- a/crates/fluke/Cargo.toml
+++ b/crates/fluke/Cargo.toml
@@ -19,8 +19,6 @@ uring = ["fluke-buffet/uring"]
 
 [dependencies]
 byteorder = "1.5.0"
-enum-repr = "0.2.6"
-enumflags2 = "0.7.9"
 eyre = { version = "0.6.12", default-features = false }
 futures-util = "0.3.30"
 fluke-buffet = { version = "0.1.0", path = "../fluke-buffet" }
@@ -37,6 +35,7 @@ smallvec = { version = "1.13.1", default-features = false, features = [
 thiserror = { version = "1.0.58", default-features = false }
 tokio = { version = "1.36.0", features = ["macros", "sync"] }
 tracing = { version = "0.1.40", default-features = false }
+fluke-h2-parse = { version = "0.1.0", path = "../fluke-h2-parse" }
 
 [dev-dependencies]
 fluke-buffet = { version = "0.1.0", path = "../fluke-buffet" }
@@ -60,3 +59,6 @@ futures-util = { version = "0.3.30", default-features = false, features = [
     "std",
 ] }
 libc = "0.2.153"
+httpwg = { path = "../httpwg" }
+color-eyre = "0.6.3"
+httpwg-macros = { path = "../httpwg-macros" }
diff --git a/crates/fluke/src/h1/body.rs b/crates/fluke/src/h1/body.rs
index 326a879b..f58ca6bc 100644
--- a/crates/fluke/src/h1/body.rs
+++ b/crates/fluke/src/h1/body.rs
@@ -273,7 +273,7 @@ pub(crate) async fn write_h1_body_chunk(
     match mode {
         BodyWriteMode::Chunked => {
             transport
-                .writev_all(
+                .writev_all_owned(
                     PieceList::default()
                         .followed_by(format!("{:x}\r\n", chunk.len()).into_bytes())
                         .followed_by(chunk)
@@ -282,7 +282,7 @@ pub(crate) async fn write_h1_body_chunk(
                 .await?;
         }
         BodyWriteMode::ContentLength => {
-            transport.write_all(chunk.into()).await?;
+            transport.write_all_owned(chunk).await?;
         }
         BodyWriteMode::Empty => {
             return Err(BodyErrorReason::CalledWriteBodyChunkWhenNoBodyWasExpected
@@ -300,7 +300,7 @@ pub(crate) async fn write_h1_body_end(
     debug!(?mode, "writing h1 body end");
     match mode {
         BodyWriteMode::Chunked => {
-            transport.write_all("0\r\n\r\n".into()).await?;
+            transport.write_all_owned("0\r\n\r\n").await?;
         }
         BodyWriteMode::ContentLength => {
             // nothing to do
diff --git a/crates/fluke/src/h1/client.rs b/crates/fluke/src/h1/client.rs
index 98c48566..a257cc65 100644
--- a/crates/fluke/src/h1/client.rs
+++ b/crates/fluke/src/h1/client.rs
@@ -58,7 +58,7 @@ where
     let mut list = PieceList::default();
     encode_request(req, &mut list, &mut buf)?;
     transport_w
-        .writev_all(list)
+        .writev_all_owned(list)
         .await
         .wrap_err("writing request headers")?;
 
diff --git a/crates/fluke/src/h1/encode.rs b/crates/fluke/src/h1/encode.rs
index 9ffc672a..5f6f5226 100644
--- a/crates/fluke/src/h1/encode.rs
+++ b/crates/fluke/src/h1/encode.rs
@@ -159,7 +159,7 @@ where
         encode_response(res, &mut list)?;
 
         self.transport_w
-            .writev_all(list)
+            .writev_all_owned(list)
             .await
             .wrap_err("writing response headers upstream")?;
 
@@ -187,7 +187,7 @@ where
         encode_headers(*trailers, &mut list)?;
 
         self.transport_w
-            .writev_all(list)
+            .writev_all_owned(list)
             .await
             .wrap_err("writing response headers upstream")?;
 
diff --git a/crates/fluke/src/h1/server.rs b/crates/fluke/src/h1/server.rs
index aaf8930f..267fa9b4 100644
--- a/crates/fluke/src/h1/server.rs
+++ b/crates/fluke/src/h1/server.rs
@@ -68,7 +68,7 @@ pub async fn serve(
             Err(e) => {
                 if let Some(se) = e.downcast_ref::<SemanticError>() {
                     transport_w
-                        .write_all(se.as_http_response().into())
+                        .write_all_owned(se.as_http_response())
                         .await
                         .wrap_err("writing error response downstream")?;
                 }
diff --git a/crates/fluke/src/h2/encode.rs b/crates/fluke/src/h2/encode.rs
index ec8938d1..b87a3c7f 100644
--- a/crates/fluke/src/h2/encode.rs
+++ b/crates/fluke/src/h2/encode.rs
@@ -2,11 +2,9 @@ use http::{StatusCode, Version};
 use tokio::sync::mpsc;
 use tracing::debug;
 
-use super::{
-    parse::StreamId,
-    types::{H2Event, H2EventPayload},
-};
+use super::types::{H2Event, H2EventPayload};
 use crate::{h1::body::BodyWriteMode, Encoder, Response};
+use fluke_h2_parse::StreamId;
 
 pub(crate) enum EncoderState {
     ExpectResponseHeaders,
diff --git a/crates/fluke/src/h2/mod.rs b/crates/fluke/src/h2/mod.rs
index a932db80..f8f7540b 100644
--- a/crates/fluke/src/h2/mod.rs
+++ b/crates/fluke/src/h2/mod.rs
@@ -4,8 +4,6 @@
 mod server;
 pub use server::*;
 
-pub(crate) mod parse;
-
 mod body;
 mod encode;
 mod types;
diff --git a/crates/fluke/src/h2/server.rs b/crates/fluke/src/h2/server.rs
index fd89526e..63e5ca3a 100644
--- a/crates/fluke/src/h2/server.rs
+++ b/crates/fluke/src/h2/server.rs
@@ -2,21 +2,24 @@ use std::{
     borrow::Cow,
     collections::HashSet,
     io::Write,
-    net::Shutdown,
     rc::Rc,
     sync::atomic::{AtomicU32, Ordering},
 };
 
 use byteorder::{BigEndian, WriteBytesExt};
-use enumflags2::BitFlags;
 use eyre::Context;
 use fluke_buffet::{Piece, PieceList, PieceStr, ReadOwned, Roll, RollMut, WriteOwned};
+use fluke_h2_parse::{
+    self as parse, enumflags2::BitFlags, nom::Finish, parse_reserved_and_u31, ContinuationFlags,
+    DataFlags, Frame, FrameType, HeadersFlags, PingFlags, PrioritySpec, Settings, SettingsFlags,
+    StreamId,
+};
 use http::{
     header,
     uri::{Authority, PathAndQuery, Scheme},
     HeaderName, Version,
 };
-use nom::Finish;
+use parse::IntoPiece;
 use smallvec::{smallvec, SmallVec};
 use tokio::sync::mpsc;
 use tracing::{debug, trace};
@@ -25,10 +28,6 @@ use crate::{
     h2::{
         body::{H2Body, PieceOrTrailers, StreamIncoming, StreamIncomingItem},
         encode::{EncoderState, H2Encoder},
-        parse::{
-            self, parse_reserved_and_u31, ContinuationFlags, DataFlags, Frame, FrameType,
-            HeadersFlags, PingFlags, PrioritySpec, Settings, SettingsFlags, StreamId,
-        },
         types::{
             BodyOutgoing, ConnState, H2ConnectionError, H2Event, H2EventPayload, H2StreamError,
             HeadersOrTrailers, HeadersOutgoing, StreamOutgoing, StreamState,
@@ -62,7 +61,7 @@ pub async fn serve(
 
     let mut cx = ServerContext::new(driver.clone(), state, transport_w)?;
     cx.work(client_buf, transport_r).await?;
-    cx.transport_w.shutdown(Shutdown::Both).await?;
+    cx.transport_w.shutdown().await?;
 
     debug!("finished serving");
     Ok(())
@@ -119,7 +118,6 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
     ) -> eyre::Result<()> {
         // first read the preface
         {
-            debug!("Reading preface");
             (client_buf, _) = match read_and_parse(
                 parse::preface,
                 &mut transport_r,
@@ -134,13 +132,12 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                     return Ok(());
                 }
             };
-            debug!("Reading preface: done");
         }
 
         // then send our initial settings
         {
             debug!("Sending initial settings");
-            let payload = self.state.self_settings.into_roll(&mut self.out_scratch)?;
+            let payload = self.state.self_settings.into_piece(&mut self.out_scratch)?;
             let frame = Frame::new(
                 FrameType::Settings(Default::default()),
                 StreamId::CONNECTION,
@@ -172,22 +169,33 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                 res = &mut deframe_task => {
                     debug!(?res, "h2 deframe task finished");
 
-                    if let Err(H2ConnectionError::ReadError(e)) = res {
-                        let mut should_ignore_err = false;
+                    if let Err(e) = res {
+                        match e {
+                            H2ConnectionError::ReadError(e) => {
+                                let mut should_ignore_err = false;
 
-                        // if this is a connection reset and we've sent a goaway, ignore it
-                        if let Some(io_error) = e.root_cause().downcast_ref::<std::io::Error>() {
-                            if io_error.kind() == std::io::ErrorKind::ConnectionReset {
-                                should_ignore_err = true;
-                            }
-                        }
+                                // if this is a connection reset and we've sent a goaway, ignore it
+                                if let Some(io_error) = e.root_cause().downcast_ref::<std::io::Error>() {
+                                    if io_error.kind() == std::io::ErrorKind::ConnectionReset {
+                                        should_ignore_err = true;
+                                    }
+                                }
 
-                        if !should_ignore_err {
-                            return Err(e.wrap_err("h2 io"));
+                                debug!(%should_ignore_err, "deciding whether or not to propagate deframer error");
+                                if !should_ignore_err {
+                                    return Err(e.wrap_err("h2 io"));
+                                }
+                            },
+                            e => {
+                                debug!("turning error into GOAWAY");
+                                goaway_err = Some(e)
+                            }
                         }
                     }
 
                     if let Err(e) = (&mut process_task).await {
+                        // what about the GOAWAY?
+
                         debug!("h2 process task finished with error: {e}");
                         return Err(e).wrap_err("h2 process");
                     }
@@ -746,18 +754,20 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
                 max_frame_size: u32::MAX,
             })?;
         debug!(?frame, ">");
-        let frame_roll = frame.into_roll(&mut self.out_scratch)?;
+        let frame_roll = frame
+            .into_piece(&mut self.out_scratch)
+            .map_err(|e| eyre::eyre!(e))?;
 
         if payload.is_empty() {
             trace!("Writing frame without payload");
             self.transport_w
-                .write_all(frame_roll.into())
+                .write_all_owned(frame_roll)
                 .await
                 .map_err(H2ConnectionError::WriteError)?;
         } else {
             trace!("Writing frame with payload");
             self.transport_w
-                .writev_all(payload.preceded_by(frame_roll))
+                .writev_all_owned(payload.preceded_by(frame_roll))
                 .await
                 .map_err(H2ConnectionError::WriteError)?;
         }
@@ -1199,10 +1209,13 @@ impl<D: ServerDriver + 'static, W: WriteOwned> ServerContext<D, W> {
         debug!("Sending rst because: {e} (known error code: {error_code:?})");
 
         debug!(%stream_id, ?error_code, "Sending RstStream");
-        let payload = self.out_scratch.put_to_roll(4, |mut slice| {
-            slice.write_u32::<BigEndian>(error_code.repr())?;
-            Ok(())
-        })?;
+        let payload = self
+            .out_scratch
+            .put_to_roll(4, |mut slice| {
+                slice.write_u32::<BigEndian>(error_code.repr())?;
+                Ok(())
+            })
+            .unwrap();
 
         let frame = Frame::new(FrameType::RstStream, stream_id)
             .with_len((payload.len()).try_into().unwrap());
diff --git a/crates/fluke/src/h2/types.rs b/crates/fluke/src/h2/types.rs
index 74501fec..d904f448 100644
--- a/crates/fluke/src/h2/types.rs
+++ b/crates/fluke/src/h2/types.rs
@@ -8,10 +8,8 @@ use tokio::sync::Notify;
 
 use crate::Response;
 
-use super::{
-    body::StreamIncoming,
-    parse::{FrameType, KnownErrorCode, Settings, StreamId},
-};
+use super::body::StreamIncoming;
+use fluke_h2_parse::{FrameType, KnownErrorCode, Settings, StreamId};
 
 pub(crate) struct ConnState {
     pub(crate) streams: HashMap<StreamId, StreamState>,
diff --git a/crates/httpwg-gen/Cargo.toml b/crates/httpwg-gen/Cargo.toml
new file mode 100644
index 00000000..f2ffb2ad
--- /dev/null
+++ b/crates/httpwg-gen/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "httpwg-gen"
+version = "0.1.0"
+edition = "2021"
+description = "Generates httpwg-macros from rustdoc's JSON output"
+
+[dependencies]
+serde = { version = "1.0.197", features = ["derive"] }
+serde_json = "1.0.114"
diff --git a/crates/httpwg-gen/src/ast.rs b/crates/httpwg-gen/src/ast.rs
new file mode 100644
index 00000000..717cdbe4
--- /dev/null
+++ b/crates/httpwg-gen/src/ast.rs
@@ -0,0 +1,74 @@
+use serde::Deserialize;
+use std::{collections::HashMap, fmt};
+
+#[derive(Deserialize, Eq, PartialEq, Hash)]
+#[serde(transparent)]
+pub struct ItemId(pub String);
+
+impl fmt::Display for ItemId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Display::fmt(&self.0, f)
+    }
+}
+
+#[derive(Deserialize)]
+pub struct Document {
+    pub format_version: i64,
+    pub root: ItemId,
+    pub index: HashMap<ItemId, Item>,
+}
+
+#[derive(Deserialize)]
+pub struct Item {
+    pub id: ItemId,
+    pub name: Option<String>,
+    pub docs: Option<String>,
+    pub inner: ItemInner,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ItemInner {
+    Module(Module),
+    Impl(Impl),
+    Function(Function),
+    StructField(StructField),
+    AssocType(AssocType),
+    Variant(Variant),
+    Enum(Enum),
+    AssocConst(AssocConst),
+    Struct(Struct),
+    Constant(Constant),
+}
+
+#[derive(Deserialize)]
+pub struct Module {
+    pub items: Vec<ItemId>,
+}
+
+#[derive(Deserialize)]
+pub struct Impl {}
+
+#[derive(Deserialize)]
+pub struct Function {}
+
+#[derive(Deserialize)]
+pub struct StructField {}
+
+#[derive(Deserialize)]
+pub struct AssocType {}
+
+#[derive(Deserialize)]
+pub struct Variant {}
+
+#[derive(Deserialize)]
+pub struct Enum {}
+
+#[derive(Deserialize)]
+pub struct AssocConst {}
+
+#[derive(Deserialize)]
+pub struct Struct {}
+
+#[derive(Deserialize)]
+pub struct Constant {}
diff --git a/crates/httpwg-gen/src/main.rs b/crates/httpwg-gen/src/main.rs
new file mode 100644
index 00000000..76c50294
--- /dev/null
+++ b/crates/httpwg-gen/src/main.rs
@@ -0,0 +1,310 @@
+use std::{
+    fs::File,
+    io::{BufRead, BufReader, Read, Write},
+    process::{Command, Stdio},
+};
+
+mod ast;
+
+fn main() {
+    let out_path = "crates/httpwg-macros/src/lib.rs";
+    if std::fs::symlink_metadata(out_path).is_err() {
+        eprintln!("⛔️ Output path doesn't exist: {out_path}");
+        eprintln!("(This tool expect to overwrite it, so the fact that it doesn't");
+        eprintln!("already exist means you're probably running it from the wrong");
+        eprintln!("directory.)");
+        eprintln!("👉 This tool should only be run from the top-level of the fluke workspace.");
+        panic!("Refusing to proceed, read stderr above");
+    }
+
+    println!("🧱 Generating rustdoc...");
+
+    let mut cmd = Command::new("cargo");
+    cmd.arg("rustdoc");
+    cmd.args(["-Z", "unstable-options"]);
+    cmd.args(["--output-format", "json"]);
+    cmd.args(["--package", "httpwg"]);
+    cmd.args(["--target-dir", "target-codegen"]);
+    cmd.arg("--frozen");
+    cmd.env("RUSTC_BOOTSTRAP", "1");
+    cmd.stdout(Stdio::piped());
+    cmd.stderr(Stdio::piped());
+    let final_cmd = format!("{cmd:?}");
+    let mut child = cmd
+        .spawn()
+        .unwrap_or_else(|err| panic!("{err} while spawning command: {final_cmd}"));
+
+    enum StdoutOrStderr {
+        Stdout,
+        Stderr,
+    }
+
+    fn collect_output(kind: StdoutOrStderr, r: impl Read) -> Vec<String> {
+        let r = BufReader::new(r);
+        let mut lines = Vec::new();
+        for l in r.lines() {
+            let l = l.unwrap();
+            match kind {
+                StdoutOrStderr::Stdout => println!("{l}"),
+                StdoutOrStderr::Stderr => eprintln!("{l}"),
+            }
+            lines.push(l)
+        }
+        lines
+    }
+
+    let stdout = std::thread::spawn({
+        let r = child.stdout.take().unwrap();
+        move || collect_output(StdoutOrStderr::Stdout, r)
+    });
+    let stderr = std::thread::spawn({
+        let r = child.stderr.take().unwrap();
+        move || collect_output(StdoutOrStderr::Stderr, r)
+    });
+
+    let status = child
+        .wait()
+        .unwrap_or_else(|err| panic!("{err} while waiting for command: {final_cmd}"));
+    if !status.success() {
+        eprintln!("command failed: {final_cmd}");
+        eprintln!("=== stdout");
+        let stdout = stdout.join().unwrap();
+        for line in stdout {
+            eprintln!("{line}");
+        }
+        eprintln!("=== stderr");
+        let stderr = stderr.join().unwrap();
+        for line in stderr {
+            eprintln!("{line}");
+        }
+        eprintln!("===");
+        panic!("command returned status {status:?}, command was: {final_cmd}")
+    }
+
+    println!("🕵️‍♂️ Parsing type info");
+    let json_path = "target-codegen/doc/httpwg.json";
+    let json_payload = std::fs::read(json_path).unwrap();
+    let doc: ast::Document = serde_json::from_slice(&json_payload).unwrap();
+    assert!(
+        doc.format_version >= 28,
+        "This tool expects JSON format version 28",
+    );
+    println!("📝 Listing tests...");
+
+    let root = doc.index.get(&doc.root).expect("Could not find root node");
+    let module = match &root.inner {
+        ast::ItemInner::Module(m) => m,
+        _ => panic!("Root has to be module"),
+    };
+
+    #[derive(Debug)]
+    struct Suite {
+        name: String,
+        docs: Option<String>,
+        groups: Vec<Group>,
+    }
+
+    #[derive(Debug)]
+    struct Group {
+        name: String,
+        docs: Option<String>,
+        tests: Vec<Test>,
+    }
+
+    #[derive(Debug)]
+    struct Test {
+        name: String,
+        docs: Option<String>,
+    }
+
+    let mut suites: Vec<Suite> = Default::default();
+
+    for item_id in &module.items {
+        let item = doc.index.get(item_id).expect("Could not find some node");
+        match &item.inner {
+            ast::ItemInner::Module(module) => {
+                let suite_name = item.name.clone().unwrap();
+                if suite_name.starts_with("rfc") {
+                    // good!
+                } else {
+                    // skip
+                    continue;
+                }
+                println!("📚 {suite_name} ({item_id})");
+                let mut suite = Suite {
+                    name: suite_name,
+                    docs: item.docs.clone(),
+                    groups: Default::default(),
+                };
+
+                for item_id in &module.items {
+                    let item = doc.index.get(item_id).expect("Could not find some node");
+                    match &item.inner {
+                        ast::ItemInner::Module(module) => {
+                            let group_name = item.name.clone().unwrap();
+                            if group_name.starts_with('_') {
+                                // good!
+                            } else {
+                                // skip
+                                continue;
+                            }
+                            println!("  📕 {group_name} ({item_id})");
+
+                            let mut group = Group {
+                                name: group_name,
+                                docs: item.docs.clone(),
+                                tests: Default::default(),
+                            };
+
+                            for item_id in &module.items {
+                                let item =
+                                    doc.index.get(item_id).expect("Could not find some node");
+                                match &item.inner {
+                                    ast::ItemInner::Function(_) => {
+                                        let test_name = item.name.clone().unwrap();
+                                        println!("    📄 {test_name} ({item_id})");
+
+                                        let test = Test {
+                                            name: test_name,
+                                            docs: item.docs.clone(),
+                                        };
+                                        group.tests.push(test);
+                                    }
+                                    _ => {
+                                        // ignore
+                                    }
+                                }
+                            }
+
+                            suite.groups.push(group);
+                        }
+                        _ => {
+                            // ignore
+                        }
+                    }
+                }
+
+                suites.push(suite);
+            }
+            _ => {
+                // ignore
+            }
+        }
+    }
+
+    // Generate macro code, pipe it to rustfmt
+    let mut cmd = Command::new("rustfmt");
+    cmd.stdin(Stdio::piped());
+    cmd.stdout(Stdio::piped());
+    cmd.stderr(Stdio::piped());
+    let final_cmd = format!("{cmd:?}");
+    let mut child = cmd
+        .spawn()
+        .unwrap_or_else(|err| panic!("{err} while spawning command: {final_cmd}"));
+
+    let stdout = std::thread::spawn({
+        let r = child.stdout.take().unwrap();
+        let mut f = File::create(out_path).unwrap();
+
+        move || {
+            let r = BufReader::new(r);
+            for line in r.lines() {
+                let line = line.unwrap();
+                writeln!(&mut f, "{line}").unwrap();
+            }
+        }
+    });
+
+    let stderr = std::thread::spawn({
+        let stderr = child.stderr.take().unwrap();
+        move || collect_output(StdoutOrStderr::Stderr, stderr)
+    });
+
+    {
+        let mut out = child.stdin.take().unwrap();
+
+        macro_rules! w {
+            ($tt:tt) => {
+                writeln!(&mut out, $tt).unwrap()
+            };
+        }
+
+        w!("//! Macros to help generate code for all suites/groups/tests of the httpwg crate");
+        w!("");
+        w!("// This file is automatically @generated by httpwg-gen");
+        w!("// It is not intended for manual editing");
+        w!("");
+        w!("/// This generates a module tree with some #[test] functions.");
+        w!("/// The `$body` argument is pasted inside those unit test, and");
+        w!("/// in that scope, `test` is the `httpwg` function you can use");
+        w!("/// to run the test (that takes a `mut conn: Conn<IO>`)");
+        w!("#[macro_export]");
+        w!("macro_rules! tests {{");
+        {
+            w!("  ($body: tt) => {{");
+            for suite in &suites {
+                let suite_name = &suite.name;
+                w!("");
+                for line in suite.docs.as_deref().unwrap_or_default().lines() {
+                    w!("/// {line}");
+                }
+                w!("#[cfg(test)]");
+                w!("mod {suite_name} {{");
+                {
+                    w!("use ::httpwg::{suite_name} as __suite;");
+                    for group in &suite.groups {
+                        let group_name = &group.name;
+                        w!("");
+                        for line in group.docs.as_deref().unwrap_or_default().lines() {
+                            w!("/// {line}");
+                        }
+                        w!("mod {group_name} {{");
+                        {
+                            w!("use super::__suite::{group_name} as __group;");
+                            for test in &group.tests {
+                                let test_name = &test.name;
+                                w!("");
+                                for line in test.docs.as_deref().unwrap_or_default().lines() {
+                                    w!("/// {line}");
+                                }
+                                w!("#[test]");
+                                w!("fn {test_name}() {{");
+                                {
+                                    w!("use __group::{test_name} as test;");
+                                    w!("$body");
+                                }
+                                w!("}}");
+                            }
+                        }
+                        w!("}}");
+                    }
+                }
+                w!("}}");
+            }
+            w!("}}");
+        }
+        w!("}}");
+
+        out.flush().unwrap();
+    }
+
+    let status = child
+        .wait()
+        .unwrap_or_else(|err| panic!("{err} while waiting for command: {final_cmd}"));
+
+    if !status.success() {
+        eprintln!("command failed: {final_cmd}");
+        eprintln!("=== stderr");
+        let stderr = stderr.join().unwrap();
+        for line in stderr {
+            eprintln!("{line}");
+        }
+        eprintln!("===");
+        panic!("command returned status {status:?}, command was: {final_cmd}")
+    }
+
+    // Make sure stdout finished successfully
+    stdout.join().unwrap();
+
+    println!("✨ httpwg-macros generated!");
+}
diff --git a/crates/httpwg-macros/Cargo.toml b/crates/httpwg-macros/Cargo.toml
new file mode 100644
index 00000000..05957e67
--- /dev/null
+++ b/crates/httpwg-macros/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "httpwg-macros"
+version = "0.1.0"
+edition = "2021"
+description = "Macros to allow generating httpwg unit tests"
+
+[dependencies]
+httpwg = { version = "0.1.0", path = "../httpwg" }
diff --git a/crates/httpwg-macros/src/lib.rs b/crates/httpwg-macros/src/lib.rs
new file mode 100644
index 00000000..a4c2d580
--- /dev/null
+++ b/crates/httpwg-macros/src/lib.rs
@@ -0,0 +1,65 @@
+//! Macros to help generate code for all suites/groups/tests of the httpwg crate
+
+// This file is automatically @generated by httpwg-gen
+// It is not intended for manual editing
+
+/// This generates a module tree with some #[test] functions.
+/// The `$body` argument is pasted inside those unit test, and
+/// in that scope, `test` is the `httpwg` function you can use
+/// to run the test (that takes a `mut conn: Conn<IO>`)
+#[macro_export]
+macro_rules! tests {
+    ($body: tt) => {
+        /// RFC 9113 describes an optimized expression of the
+        /// semantics of the Hypertext Transfer Protocol (HTTP), referred to as
+        /// HTTP version 2 (HTTP/2).
+        ///
+        /// HTTP/2 enables a more efficient use of network resources and a reduced
+        /// latency by introducing field compression and allowing multiple concurrent
+        /// exchanges on the same connection.
+        ///
+        /// This document obsoletes RFCs 7540 and 8740.
+        ///
+        /// cf. <https://httpwg.org/specs/rfc9113.html>
+        #[cfg(test)]
+        mod rfc9113 {
+            use httpwg::rfc9113 as __suite;
+
+            /// Section 3: Starting HTTP/2
+            mod _3_starting_http2 {
+                use super::__suite::_3_starting_http2 as __group;
+
+                /// 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.
+                #[test]
+                fn sends_client_connection_preface() {
+                    use __group::sends_client_connection_preface as test;
+                    $body
+                }
+
+                /// Clients and servers MUST treat an invalid connection preface as
+                /// a connection error (Section 5.4.1) of type PROTOCOL_ERROR.
+                #[test]
+                fn sends_invalid_connection_preface() {
+                    use __group::sends_invalid_connection_preface as test;
+                    $body
+                }
+            }
+
+            /// Section 4.2: Frame Size
+            mod _4_2_frame_size {
+                use super::__suite::_4_2_frame_size as __group;
+
+                /// An endpoint MUST send an error code of FRAME_SIZE_ERROR if a frame
+                /// 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
+                #[test]
+                fn frame_exceeding_max_size() {
+                    use __group::frame_exceeding_max_size as test;
+                    $body
+                }
+            }
+        }
+    };
+}
diff --git a/crates/httpwg/Cargo.toml b/crates/httpwg/Cargo.toml
new file mode 100644
index 00000000..bb890d03
--- /dev/null
+++ b/crates/httpwg/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "httpwg"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+bytes = "1.5.0"
+enumflags2 = "0.7.9"
+eyre = "0.6.12"
+fluke-buffet = { version = "0.1.0", path = "../fluke-buffet" }
+fluke-h2-parse = { version = "0.1.0", path = "../fluke-h2-parse" }
+fluke-hpack = { version = "0.3.0", 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
new file mode 100644
index 00000000..d2916714
--- /dev/null
+++ b/crates/httpwg/src/lib.rs
@@ -0,0 +1,565 @@
+use eyre::eyre;
+use multimap::MultiMap;
+use rfc9113::DEFAULT_FRAME_SIZE;
+use std::{rc::Rc, time::Duration};
+
+use enumflags2::{bitflags, BitFlags};
+use fluke_buffet::{IntoHalves, Piece, PieceList, Roll, RollMut, WriteOwned};
+use fluke_h2_parse::{
+    enumflags2,
+    nom::{self, Finish},
+    DataFlags, Frame, FrameType, GoAway, HeadersFlags, IntoPiece, KnownErrorCode, RstStream,
+    Settings, SettingsFlags, StreamId, PREFACE,
+};
+use tokio::time::Instant;
+use tracing::debug;
+
+use crate::rfc9113::default_settings;
+
+pub mod rfc9113;
+
+pub type Headers = MultiMap<&'static str, Piece>;
+
+pub struct Conn<IO: IntoHalves + 'static> {
+    w: <IO as IntoHalves>::Write,
+    scratch: RollMut,
+    pub ev_rx: tokio::sync::mpsc::Receiver<Ev>,
+    config: Rc<Config>,
+    hpack_enc: fluke_hpack::Encoder<'static>,
+    // FIXME: we should decode header fragments for _all_ HEADERS frames,
+    // even the ones we discard. I don't know if test cases should be
+    // responsible for that, or the connection itself.
+    hpack_dec: fluke_hpack::Decoder<'static>,
+    pub max_frame_size: usize,
+}
+
+pub enum Ev {
+    Frame { frame: Frame, payload: Roll },
+    IoError { error: std::io::Error },
+}
+
+pub enum FrameWaitOutcome {
+    Success(Frame, Roll),
+    Timeout {
+        wanted: BitFlags<FrameT>,
+        last_frame: Option<Frame>,
+        waited: Duration,
+    },
+    Eof {
+        wanted: BitFlags<FrameT>,
+        last_frame: Option<Frame>,
+    },
+    IoError {
+        wanted: BitFlags<FrameT>,
+        last_frame: Option<Frame>,
+        error: std::io::Error,
+    },
+}
+
+impl FrameWaitOutcome {
+    pub fn unwrap(self) -> (Frame, Roll) {
+        match self {
+            FrameWaitOutcome::Success(frame, payload) => (frame, payload),
+            FrameWaitOutcome::Timeout {
+                wanted,
+                last_frame,
+                waited,
+            } => {
+                panic!(
+                    "Wanted ({wanted:?}), timed out after {waited:?}. Last frame: {last_frame:?}"
+                );
+            }
+            FrameWaitOutcome::Eof { wanted, last_frame } => {
+                panic!("Wanted ({wanted:?}), peer hung up. Last frame: {last_frame:?}");
+            }
+            FrameWaitOutcome::IoError {
+                wanted,
+                last_frame,
+                error,
+            } => {
+                panic!("Wanted ({wanted:?}), got I/O error {error}. Last frame: {last_frame:?}")
+            }
+        }
+    }
+}
+
+/// A "hollow" variant of [FrameType], with no associated data.
+/// Useful to expect a certain frame type
+#[bitflags]
+#[repr(u16)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum FrameT {
+    Data,
+    Headers,
+    Priority,
+    RstStream,
+    Settings,
+    PushPromise,
+    Ping,
+    GoAway,
+    WindowUpdate,
+    Continuation,
+    Unknown,
+}
+
+impl From<FrameType> for FrameT {
+    fn from(value: FrameType) -> Self {
+        match value {
+            FrameType::Data(_) => Self::Data,
+            FrameType::Headers(_) => Self::Headers,
+            FrameType::Priority => Self::Priority,
+            FrameType::RstStream => Self::RstStream,
+            FrameType::Settings(_) => Self::Settings,
+            FrameType::PushPromise => Self::PushPromise,
+            FrameType::Ping(_) => Self::Ping,
+            FrameType::GoAway => Self::GoAway,
+            FrameType::WindowUpdate => Self::WindowUpdate,
+            FrameType::Continuation(_) => Self::Continuation,
+            FrameType::Unknown(_) => Self::Unknown,
+        }
+    }
+}
+
+// A hollow variant of [KnownErrorCode]
+#[bitflags]
+#[repr(u16)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum ErrorC {
+    NoError,
+    ProtocolError,
+    InternalError,
+    FlowControlError,
+    SettingsTimeout,
+    StreamClosed,
+    FrameSizeError,
+    RefusedStream,
+    Cancel,
+    CompressionError,
+    ConnectError,
+    EnhanceYourCalm,
+    InadequateSecurity,
+    Http1_1Required,
+}
+
+impl From<KnownErrorCode> for ErrorC {
+    fn from(value: KnownErrorCode) -> Self {
+        match value {
+            KnownErrorCode::NoError => Self::NoError,
+            KnownErrorCode::ProtocolError => Self::ProtocolError,
+            KnownErrorCode::InternalError => Self::InternalError,
+            KnownErrorCode::FlowControlError => Self::FlowControlError,
+            KnownErrorCode::SettingsTimeout => Self::SettingsTimeout,
+            KnownErrorCode::StreamClosed => Self::StreamClosed,
+            KnownErrorCode::FrameSizeError => Self::FrameSizeError,
+            KnownErrorCode::RefusedStream => Self::RefusedStream,
+            KnownErrorCode::Cancel => Self::Cancel,
+            KnownErrorCode::CompressionError => Self::CompressionError,
+            KnownErrorCode::ConnectError => Self::ConnectError,
+            KnownErrorCode::EnhanceYourCalm => Self::EnhanceYourCalm,
+            KnownErrorCode::InadequateSecurity => Self::InadequateSecurity,
+            KnownErrorCode::Http1_1Required => Self::Http1_1Required,
+        }
+    }
+}
+
+impl<IO: IntoHalves> Conn<IO> {
+    pub fn new(config: Rc<Config>, io: IO) -> Self {
+        let (mut r, w) = io.into_halves();
+
+        let (ev_tx, ev_rx) = tokio::sync::mpsc::channel::<Ev>(1);
+        let mut eof = false;
+        let recv_fut = async move {
+            let mut res_buf = RollMut::alloc()?;
+            'read: loop {
+                if !eof {
+                    res_buf.reserve()?;
+                    let res;
+                    (res, res_buf) = res_buf.read_into(16384, &mut r).await;
+                    let n = res?;
+                    if n == 0 {
+                        debug!("reached EOF");
+                        eof = true;
+                    } else {
+                        debug!(%n, "read bytes (reading frame header)");
+                    }
+                }
+
+                if eof && res_buf.is_empty() {
+                    break 'read;
+                }
+
+                match Frame::parse(res_buf.filled()) {
+                    Ok((rest, frame)) => {
+                        res_buf.keep(rest);
+                        debug!("< {frame:?}");
+
+                        // read frame payload
+                        let frame_len = frame.len as usize;
+                        res_buf.reserve_at_least(frame_len)?;
+
+                        while res_buf.len() < frame_len {
+                            let res;
+                            (res, res_buf) = res_buf.read_into(16384, &mut r).await;
+                            let n = res?;
+                            debug!(%n, len = %res_buf.len(), "read bytes (reading frame payload)");
+
+                            if n == 0 {
+                                eof = true;
+                                if res_buf.len() < frame_len {
+                                    panic!(
+                                        "peer frame header, then incomplete payload, then hung up"
+                                    )
+                                }
+                            }
+                        }
+
+                        let payload = if frame_len == 0 {
+                            Roll::empty()
+                        } else {
+                            res_buf.take_at_most(frame_len).unwrap()
+                        };
+                        assert_eq!(payload.len(), frame_len);
+
+                        debug!(%frame_len, "got frame payload");
+                        ev_tx.send(Ev::Frame { frame, payload }).await.unwrap();
+                    }
+                    Err(nom::Err::Incomplete(_)) => {
+                        if eof {
+                            panic!(
+                                "peer sent incomplete frame header then hung up (buf len: {})",
+                                res_buf.len()
+                            )
+                        }
+
+                        continue;
+                    }
+                    Err(nom::Err::Failure(err) | nom::Err::Error(err)) => {
+                        debug!(?err, "got parse error");
+                        break;
+                    }
+                }
+            }
+
+            Ok::<_, eyre::Report>(())
+        };
+        fluke_buffet::spawn(async move { recv_fut.await.unwrap() });
+
+        Self {
+            w,
+            scratch: RollMut::alloc().unwrap(),
+            ev_rx,
+            config,
+            hpack_enc: Default::default(),
+            hpack_dec: Default::default(),
+            max_frame_size: DEFAULT_FRAME_SIZE as usize,
+        }
+    }
+
+    pub async fn write_frame(&mut self, frame: Frame, payload: impl IntoPiece) -> eyre::Result<()> {
+        let payload = payload.into_piece(&mut self.scratch)?;
+        let frame = frame.with_len(payload.len().try_into().unwrap());
+
+        let header = frame.into_piece(&mut self.scratch)?;
+        self.w
+            .writev_all_owned(PieceList::single(header).followed_by(payload))
+            .await?;
+        Ok(())
+    }
+
+    pub async fn write_settings(&mut self, settings: Settings) -> eyre::Result<()> {
+        self.write_frame(
+            FrameType::Settings(Default::default()).into_frame(StreamId::CONNECTION),
+            settings,
+        )
+        .await
+    }
+
+    /// Waits for a certain kind of frame
+    pub async fn wait_for_frame(&mut self, types: impl Into<BitFlags<FrameT>>) -> FrameWaitOutcome {
+        let deadline = Instant::now() + self.config.timeout;
+
+        let types = types.into();
+        let mut last_frame: Option<Frame> = None;
+
+        loop {
+            match tokio::time::timeout_at(deadline, self.ev_rx.recv()).await {
+                Err(_) => {
+                    return FrameWaitOutcome::Timeout {
+                        wanted: types,
+                        last_frame,
+                        waited: self.config.timeout,
+                    };
+                }
+                Ok(maybe_ev) => match maybe_ev {
+                    None => {
+                        return FrameWaitOutcome::Eof {
+                            wanted: types,
+                            last_frame,
+                        }
+                    }
+                    Some(ev) => match ev {
+                        Ev::Frame { frame, payload } => {
+                            if types.contains(FrameT::from(frame.frame_type)) {
+                                return FrameWaitOutcome::Success(frame, payload);
+                            } else {
+                                last_frame = Some(frame)
+                            }
+                        }
+                        Ev::IoError { error } => {
+                            return FrameWaitOutcome::IoError {
+                                wanted: types,
+                                last_frame,
+                                error,
+                            }
+                        }
+                    },
+                },
+            }
+        }
+    }
+
+    pub async fn handshake(&mut self) -> eyre::Result<()> {
+        // perform an HTTP/2 handshake as a client
+        self.w.write_all_owned(PREFACE).await?;
+
+        self.write_settings(default_settings()).await?;
+
+        let (frame, payload) = self.wait_for_frame(FrameT::Settings).await.unwrap();
+        assert!(
+            !frame.is_ack(),
+            "server should send their settings first thing (no ack)"
+        );
+
+        {
+            // parse settings
+            let (_rest, settings) = Settings::parse(payload).finish().unwrap();
+            self.max_frame_size = settings.max_frame_size as _;
+        }
+
+        self.write_frame(
+            Frame::new(
+                FrameType::Settings(SettingsFlags::Ack.into()),
+                StreamId::CONNECTION,
+            ),
+            (),
+        )
+        .await?;
+
+        // and wait until the server acknowledges our settings
+        let (frame, _payload) = self.wait_for_frame(FrameT::Settings).await.unwrap();
+        assert!(frame.is_ack(), "server should acknowledge our settings");
+
+        Ok(())
+    }
+
+    pub async fn send(&mut self, buf: impl Into<Piece>) -> eyre::Result<()> {
+        self.w.write_all_owned(buf.into()).await?;
+        Ok(())
+    }
+
+    async fn verify_connection_error(
+        &mut self,
+        codes: impl Into<BitFlags<ErrorC>>,
+    ) -> eyre::Result<()> {
+        let codes = codes.into();
+
+        match self.wait_for_frame(FrameT::GoAway).await {
+            FrameWaitOutcome::Success(_frame, payload) => {
+                let (_, goaway) = GoAway::parse(payload).finish().unwrap();
+                let error_c: ErrorC = KnownErrorCode::try_from(goaway.error_code)
+                    .map_err(|_| eyre::eyre!(
+                        "Expected GOAWAY with one of {codes:?}, but got unknown error code {} (0x{:x})",
+                        goaway.error_code.as_repr(), goaway.error_code.as_repr()
+                    ))?
+                    .into();
+
+                if codes.contains(error_c) {
+                    // that's what we expected!
+                    return Ok(());
+                }
+                Err(eyre::eyre!(
+                    "Expected GOAWAY with one of {codes:?}, but got {error_c:?}"
+                ))
+            }
+            FrameWaitOutcome::Timeout { last_frame, .. } => Err(eyre!(
+                "Timed out while waiting for connection error, last frame: ({last_frame:?})"
+            )),
+            FrameWaitOutcome::Eof { .. } => {
+                // that's fine
+                Ok(())
+            }
+            FrameWaitOutcome::IoError { .. } => {
+                // TODO: that's fine if it's a connection reset, we should probably check
+                Ok(())
+            }
+        }
+    }
+
+    async fn verify_stream_error(
+        &mut self,
+        codes: impl Into<BitFlags<ErrorC>>,
+    ) -> eyre::Result<()> {
+        let codes = codes.into();
+
+        match self
+            .wait_for_frame(FrameT::GoAway | FrameT::RstStream)
+            .await
+        {
+            FrameWaitOutcome::Success(frame, payload) => {
+                match frame.frame_type {
+                    FrameType::GoAway => {
+                        let (_, goaway) = GoAway::parse(payload).finish().unwrap();
+                        let error_c: ErrorC = KnownErrorCode::try_from(goaway.error_code)
+                            .map_err(|_| eyre::eyre!(
+                                "Expected GOAWAY or RSTSTREAM with one of {codes:?}, but got unknown error code {} (0x{:x})",
+                                goaway.error_code.as_repr(), goaway.error_code.as_repr()
+                            ))?
+                            .into();
+
+                        if codes.contains(error_c) {
+                            // that's what we expected!
+                            Ok(())
+                        } else {
+                            Err(eyre::eyre!(
+                                "Expected GOAWAY or RSTSTREAM with one of {codes:?}, but got {error_c:?}"
+                            ))
+                        }
+                    }
+                    FrameType::RstStream => {
+                        let (_, rststream) = RstStream::parse(payload).finish().unwrap();
+                        let error_code = KnownErrorCode::try_from(rststream.error_code)
+                            .map_err(|_| eyre::eyre!(
+                                "Expected GOAWAY or RSTSTREAM with one of {codes:?}, but got unknown error code {} (0x{:x})",
+                                rststream.error_code.as_repr(), rststream.error_code.as_repr()
+                            ))?;
+
+                        let error_c: ErrorC = error_code.into();
+
+                        if codes.contains(error_c) {
+                            // that's what we expected!
+                            Ok(())
+                        } else {
+                            Err(eyre::eyre!(
+                                "Expected GOAWAY or RSTSTREAM with one of {codes:?}, but got {error_c:?}"
+                            ))
+                        }
+                    }
+                    _ => unreachable!(),
+                }
+            }
+            FrameWaitOutcome::Timeout { last_frame, .. } => Err(eyre!(
+                "Timed out while waiting for stream error, last frame: ({last_frame:?})"
+            )),
+            FrameWaitOutcome::Eof { .. } => {
+                // that's fine
+                Ok(())
+            }
+            FrameWaitOutcome::IoError { .. } => {
+                // TODO: that's fine if it's a connection reset, we should probably check
+                Ok(())
+            }
+        }
+    }
+
+    fn common_headers(&self) -> Headers {
+        let (scheme, default_port) = if self.config.tls {
+            ("https", self.config.port == 443)
+        } else {
+            ("http", self.config.port == 80)
+        };
+
+        let authority = if default_port {
+            self.config.host.clone()
+        } else {
+            format!("{}:{}", self.config.host, self.config.port)
+        };
+
+        let mut headers = Headers::default();
+        headers.insert(":method", "POST".into());
+        headers.insert(":scheme", scheme.into());
+        headers.insert(":path", self.config.path.clone().into_bytes().into());
+        headers.insert(":authority", authority.into_bytes().into());
+        headers
+    }
+
+    pub fn encode_headers(&mut self, headers: &Headers) -> eyre::Result<Piece> {
+        // wasteful, but we're doing tests so shrug.
+        let mut fragment = Vec::new();
+        for (k, v) in headers.iter() {
+            self.hpack_enc
+                .encode_header_into((k.as_ref(), v.as_ref()), &mut fragment)?;
+        }
+        Ok(fragment.into())
+    }
+
+    pub async fn write_headers(
+        &mut self,
+        block_fragment: Piece,
+        stream_id: StreamId,
+        flags: impl Into<BitFlags<HeadersFlags>>,
+    ) -> eyre::Result<()> {
+        let flags = flags.into();
+        let frame = Frame::new(FrameType::Headers(flags), stream_id);
+        self.write_frame(frame, block_fragment).await?;
+        Ok(())
+    }
+
+    pub async fn write_data(
+        &mut self,
+        stream_id: StreamId,
+        end_stream: bool,
+        data: impl Into<Piece>,
+    ) -> eyre::Result<()> {
+        let frame = Frame::new(
+            FrameType::Data(if end_stream {
+                DataFlags::EndStream.into()
+            } else {
+                Default::default()
+            }),
+            stream_id,
+        );
+        self.write_frame(frame, data.into()).await?;
+        Ok(())
+    }
+}
+
+/// Parameters for tests
+pub struct Config {
+    /// which host to connect to
+    pub host: String,
+
+    /// which port to connect to
+    pub port: u16,
+
+    /// which path to request
+    pub path: String,
+
+    /// whether to use TLS
+    pub tls: bool,
+
+    /// how long to wait for a frame
+    pub timeout: Duration,
+}
+
+impl Default for Config {
+    fn default() -> Self {
+        Self {
+            host: "localhost".into(),
+            port: 80,
+            path: "/".into(),
+            tls: false,
+
+            timeout: Duration::from_secs(1),
+        }
+    }
+}
+
+// DummyString returns a dummy string with specified length.
+pub fn dummy_string(len: usize) -> String {
+    "x".repeat(len)
+}
+
+// DummyBytes returns an array of bytes with specified length.
+pub fn dummy_bytes(len: usize) -> Vec<u8> {
+    vec![b'x'; len]
+}
diff --git a/crates/httpwg/src/rfc9113/_3_starting_http2.rs b/crates/httpwg/src/rfc9113/_3_starting_http2.rs
new file mode 100644
index 00000000..b0f0f306
--- /dev/null
+++ b/crates/httpwg/src/rfc9113/_3_starting_http2.rs
@@ -0,0 +1,35 @@
+//! Section 3: Starting HTTP/2
+
+use fluke_buffet::IntoHalves;
+use fluke_h2_parse::PREFACE;
+
+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>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.send(PREFACE).await?;
+
+    let settings = default_settings();
+    conn.write_settings(settings).await?;
+
+    let (frame, _) = conn.wait_for_frame(FrameT::Settings).await.unwrap();
+    assert!(!frame.is_ack(), "The server connection preface MUST be the first frame the server sends in the HTTP/2 connection.");
+
+    Ok(())
+}
+
+/// 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>(
+    mut conn: Conn<IO>,
+) -> eyre::Result<()> {
+    conn.send(&b"INVALID CONNECTION PREFACE\r\n\r\n"[..])
+        .await?;
+    conn.verify_connection_error(ErrorC::ProtocolError).await?;
+
+    Ok(())
+}
diff --git a/crates/httpwg/src/rfc9113/_4_2_frame_size.rs b/crates/httpwg/src/rfc9113/_4_2_frame_size.rs
new file mode 100644
index 00000000..1e19bab5
--- /dev/null
+++ b/crates/httpwg/src/rfc9113/_4_2_frame_size.rs
@@ -0,0 +1,33 @@
+//! Section 4.2: Frame Size
+
+use fluke_buffet::IntoHalves;
+use fluke_h2_parse::{HeadersFlags, StreamId};
+
+use crate::{dummy_bytes, Conn, ErrorC};
+
+/// An endpoint MUST send an error code of FRAME_SIZE_ERROR if a frame
+/// 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<()> {
+    let stream_id = StreamId(1);
+
+    conn.handshake().await?;
+
+    let mut headers = conn.common_headers();
+    headers.insert(":method", "POST".into());
+    let block_fragment = conn.encode_headers(&headers)?;
+
+    conn.write_headers(block_fragment, stream_id, HeadersFlags::EndHeaders)
+        .await?;
+
+    // this is okay if it fails
+    _ = conn
+        .write_data(stream_id, true, dummy_bytes(conn.max_frame_size + 1))
+        .await;
+
+    conn.verify_stream_error(ErrorC::FrameSizeError).await?;
+
+    Ok(())
+}
diff --git a/crates/httpwg/src/rfc9113/mod.rs b/crates/httpwg/src/rfc9113/mod.rs
new file mode 100644
index 00000000..a5fe082c
--- /dev/null
+++ b/crates/httpwg/src/rfc9113/mod.rs
@@ -0,0 +1,27 @@
+//! RFC 9113 describes an optimized expression of the
+//! semantics of the Hypertext Transfer Protocol (HTTP), referred to as
+//! HTTP version 2 (HTTP/2).
+//!
+//! HTTP/2 enables a more efficient use of network resources and a reduced
+//! latency by introducing field compression and allowing multiple concurrent
+//! exchanges on the same connection.
+//!
+//! This document obsoletes RFCs 7540 and 8740.
+//!
+//! cf. <https://httpwg.org/specs/rfc9113.html>
+
+use fluke_h2_parse::Settings;
+
+pub const DEFAULT_WINDOW_SIZE: u32 = 65536;
+pub const DEFAULT_FRAME_SIZE: u32 = 16384;
+
+pub fn default_settings() -> Settings {
+    Settings {
+        initial_window_size: DEFAULT_WINDOW_SIZE,
+        max_frame_size: DEFAULT_FRAME_SIZE,
+        ..Default::default()
+    }
+}
+
+pub mod _3_starting_http2;
+pub mod _4_2_frame_size;
diff --git a/flake.lock b/flake.lock
deleted file mode 100644
index 067abb38..00000000
--- a/flake.lock
+++ /dev/null
@@ -1,106 +0,0 @@
-{
-  "nodes": {
-    "crane": {
-      "inputs": {
-        "nixpkgs": [
-          "nixpkgs"
-        ]
-      },
-      "locked": {
-        "lastModified": 1712681629,
-        "narHash": "sha256-bMDXn4AkTXLCpoZbII6pDGoSeSe9gI87jxPsHRXgu/E=",
-        "owner": "ipetkov",
-        "repo": "crane",
-        "rev": "220387ac8e99cbee0ca4c95b621c4bc782b6a235",
-        "type": "github"
-      },
-      "original": {
-        "owner": "ipetkov",
-        "repo": "crane",
-        "type": "github"
-      }
-    },
-    "flake-utils": {
-      "inputs": {
-        "systems": "systems"
-      },
-      "locked": {
-        "lastModified": 1710146030,
-        "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
-        "type": "github"
-      },
-      "original": {
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "type": "github"
-      }
-    },
-    "nixpkgs": {
-      "locked": {
-        "lastModified": 1713248628,
-        "narHash": "sha256-NLznXB5AOnniUtZsyy/aPWOk8ussTuePp2acb9U+ISA=",
-        "owner": "NixOS",
-        "repo": "nixpkgs",
-        "rev": "5672bc9dbf9d88246ddab5ac454e82318d094bb8",
-        "type": "github"
-      },
-      "original": {
-        "owner": "NixOS",
-        "ref": "nixos-unstable",
-        "repo": "nixpkgs",
-        "type": "github"
-      }
-    },
-    "root": {
-      "inputs": {
-        "crane": "crane",
-        "flake-utils": "flake-utils",
-        "nixpkgs": "nixpkgs",
-        "rust-overlay": "rust-overlay"
-      }
-    },
-    "rust-overlay": {
-      "inputs": {
-        "flake-utils": [
-          "flake-utils"
-        ],
-        "nixpkgs": [
-          "nixpkgs"
-        ]
-      },
-      "locked": {
-        "lastModified": 1713320003,
-        "narHash": "sha256-HY8uzaDMzn6umca2kE4ce5BEujaw+gvEiC5xTwAHSC4=",
-        "owner": "oxalica",
-        "repo": "rust-overlay",
-        "rev": "7ce479797ae35ab40fbdf04fe5a8f8bfc92a2634",
-        "type": "github"
-      },
-      "original": {
-        "owner": "oxalica",
-        "repo": "rust-overlay",
-        "type": "github"
-      }
-    },
-    "systems": {
-      "locked": {
-        "lastModified": 1681028828,
-        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
-        "owner": "nix-systems",
-        "repo": "default",
-        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
-        "type": "github"
-      },
-      "original": {
-        "owner": "nix-systems",
-        "repo": "default",
-        "type": "github"
-      }
-    }
-  },
-  "root": "root",
-  "version": 7
-}
diff --git a/flake.nix b/flake.nix
deleted file mode 100644
index 1fa85735..00000000
--- a/flake.nix
+++ /dev/null
@@ -1,72 +0,0 @@
-{
-  inputs = {
-    flake-utils = { url = "github:numtide/flake-utils"; };
-    nixpkgs = { url = "github:NixOS/nixpkgs/nixos-unstable"; };
-    rust-overlay =
-      {
-        url = "github:oxalica/rust-overlay";
-        inputs = {
-          nixpkgs.follows = "nixpkgs";
-          flake-utils.follows = "flake-utils";
-        };
-      };
-    crane = {
-      url = "github:ipetkov/crane";
-      inputs = {
-        nixpkgs.follows = "nixpkgs";
-      };
-    };
-  };
-  outputs =
-    { self, nixpkgs, flake-utils, rust-overlay, crane }:
-    flake-utils.lib.eachDefaultSystem (system:
-    let
-      pkgs = import nixpkgs {
-        inherit system;
-        overlays = [ (import rust-overlay) ];
-      };
-      rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
-      craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
-      src = craneLib.cleanCargoSource (craneLib.path ./.);
-
-      buildInputs = with pkgs; [
-        pkgs.stdenv.cc.cc
-        lld
-      ];
-      nativeBuildInputs = with pkgs; [
-        rustToolchain
-        clang
-        curl
-      ]
-      ++ lib.optionals pkgs.stdenv.isLinux [ autoPatchelfHook ]
-      ++ lib.optionals pkgs.stdenv.isDarwin
-        (with pkgs.darwin.apple_sdk.frameworks; [
-          CoreFoundation
-          CoreServices
-          SystemConfiguration
-          Security
-        ]);
-      commonArgs = {
-        pname = "fluke";
-        version = "latest";
-        strictDeps = true;
-        dontStrip = true;
-        inherit src buildInputs nativeBuildInputs;
-      };
-      cargoArtifacts = craneLib.buildDepsOnly commonArgs;
-      bin = craneLib.buildPackage (commonArgs // {
-        inherit cargoArtifacts;
-      });
-    in
-    with pkgs;
-    {
-      packages = {
-        inherit bin;
-        default = bin;
-      };
-      devShells.default = mkShell {
-        packages = with pkgs; [ clang lld just nixpkgs-fmt cargo-nextest libiconv cmake pkg-config curl ];
-      };
-    }
-    );
-}
diff --git a/scripts/.gitignore b/scripts/.gitignore
deleted file mode 100644
index b512c09d..00000000
--- a/scripts/.gitignore
+++ /dev/null
@@ -1,1 +0,0 @@
-node_modules
\ No newline at end of file
diff --git a/scripts/cov.sh b/scripts/cov.sh
deleted file mode 100755
index c075c749..00000000
--- a/scripts/cov.sh
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/bin/bash -eux
-
-export RUSTUP_TOOLCHAIN=nightly-2024-05-09
-export RUSTFLAGS='-C instrument-coverage -Z coverage-options=branch --cfg=coverage --cfg=trybuild_no_target'
-
-export CARGO_INCREMENTAL=0
-export CARGO_TARGET_DIR="${PWD}/target-cov"
-
-rustup component add llvm-tools
-
-export COVERAGE_DIR="${PWD}/coverage"
-rm -rf "${COVERAGE_DIR}"
-
-mkdir -p "${COVERAGE_DIR}/raw"
-# must be set before building
-export LLVM_PROFILE_FILE="${COVERAGE_DIR}/raw/fluke-%p-%12m.profraw"
-
-set +x
-declare -a RUSTC_TARGETS
-RUSTC_TARGETS=$(rustc --print target-list)
-RUSTC_SYSROOT=$(rustc --print sysroot)
-# go through each target in the target list, find which one is
-# at the end of RUSTC_SYSROOT
-RUSTC_TARGET_TRIPLE="unknown"
-for target in $RUSTC_TARGETS; do
-  if [[ $RUSTC_SYSROOT == *$target ]]; then
-    RUSTC_TARGET_TRIPLE=$target
-    break
-  fi
-done
-set -x
-
-LLVM_TOOLS_PATH=${RUSTC_SYSROOT}/lib/rustlib/${RUSTC_TARGET_TRIPLE}/bin
-LLVM_PROFDATA="${LLVM_TOOLS_PATH}/llvm-profdata"
-"${LLVM_PROFDATA}" --version
-LLVM_COV="${LLVM_TOOLS_PATH}/llvm-cov"
-"${LLVM_COV}" --version
-
-cargo nextest run --verbose --profile ci --cargo-profile ci --features fluke-hpack/interop-tests
-
-cargo build --profile ci --manifest-path crates/fluke-h2spec/Cargo.toml
-# skip if SKIP_H2SPEC is set to 1
-if [[ "${SKIP_H2SPEC:-0}" == "1" ]]; then
-  echo "Skipping h2spec suites"
-else
-  for suite in generic hpack http2; do
-    "${CARGO_TARGET_DIR}"/ci/fluke-h2spec "${suite}" -j "target/h2spec-${suite}.xml"
-  done
-fi
-
-# merge all profiles
-"${LLVM_PROFDATA}" merge -sparse "${COVERAGE_DIR}/raw"/*.profraw -o "${COVERAGE_DIR}/fluke.profdata"
-
-# build llvm-cov argument list
-cover_args=()
-cover_args+=(--instr-profile "${COVERAGE_DIR}/fluke.profdata")
-cover_args+=(--ignore-filename-regex "rustc|.cargo|non_uring")
-cover_args+=("${CARGO_TARGET_DIR}"/ci/fluke-h2spec)
-
-set +x
-objects=("${CARGO_TARGET_DIR}"/ci/deps/*)
-for object in "${objects[@]}"; do
-  # skip directories
-  [[ -d $object ]] && continue
-
-  # skip debug files '.d'
-  [[ $object =~ \.d$ ]] && continue
-
-  # skip rust metadata files '.rmeta'
-  [[ $object =~ \.rmeta$ ]] && continue
-
-  cover_args+=(-object "$object")
-done
-
-"${LLVM_COV}" export --format=lcov "${cover_args[@]}" > coverage/lcov.info
-"${LLVM_COV}" show --format=html --output-dir=coverage/html "${cover_args[@]}"
-"${LLVM_COV}" report --format=text "${cover_args[@]}"
diff --git a/static/rfcs/rfc7541.txt.pdf b/static/rfcs/rfc7541.txt.pdf
new file mode 100644
index 00000000..f83024da
Binary files /dev/null and b/static/rfcs/rfc7541.txt.pdf differ
diff --git a/static/rfcs/rfc9110.pdf b/static/rfcs/rfc9110.pdf
new file mode 100644
index 00000000..f7de35b1
Binary files /dev/null and b/static/rfcs/rfc9110.pdf differ
diff --git a/static/rfcs/rfc9111.pdf b/static/rfcs/rfc9111.pdf
new file mode 100644
index 00000000..52269e58
Binary files /dev/null and b/static/rfcs/rfc9111.pdf differ
diff --git a/static/rfcs/rfc9112.pdf b/static/rfcs/rfc9112.pdf
new file mode 100644
index 00000000..b6a679f6
Binary files /dev/null and b/static/rfcs/rfc9112.pdf differ
diff --git a/static/rfcs/rfc9113.pdf b/static/rfcs/rfc9113.pdf
new file mode 100644
index 00000000..39329b70
Binary files /dev/null and b/static/rfcs/rfc9113.pdf differ
diff --git a/static/rfcs/rfc9204.pdf b/static/rfcs/rfc9204.pdf
new file mode 100644
index 00000000..352c9f53
Binary files /dev/null and b/static/rfcs/rfc9204.pdf differ


Test patch:
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 2703d01a..1cafc3a4 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -1,8 +1,11 @@
 [profile.default]
 slow-timeout = { period = "5s", terminate-after = 3 }
 
+[profile.http]
+slow-timeout = { period = "1s", terminate-after = 1 }
+
 [profile.miri]
 slow-timeout = { period = "60s", terminate-after = 5 }
 
 [profile.ci]
-slow-timeout = { period = "5s", terminate-after = 6 }
\ No newline at end of file
+slow-timeout = { period = "5s", terminate-after = 6 }
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 884ed7d5..3b65777d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -52,17 +52,11 @@ jobs:
           rustc --version
       - uses: taiki-e/install-action@v2
         with:
-          tool: just,cargo-nextest
-      - name: Run unit tests and h2spec
+          tool: just,cargo-nextest,cargo-llvm-cov
+      - name: Run tests
         run: |
           cd ${{ github.workspace }}
           cargo clippy
-          mkdir tools
-          export PATH=$PATH:${PWD}/tools
-          # (pushd tools && curl -L https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz | tar -xz && popd)
-          # Uses https://github.com/summerwind/h2spec/pull/123, more precisely
-          # https://github.com/fasterthanlime/h2spec/commit/72a6b9b2b01133d292bd74e019fde86c3638094a
-          (pushd tools && curl -fL https://github.com/bearcove/h2spec-binaries/releases/download/72a6b9b/h2spec -o h2spec && chmod +x h2spec && popd)
           just ci-test
       - name: Upload coverage information
         uses: codecov/codecov-action@v4
diff --git a/crates/fluke-curl-tests/tests/integration_test.rs b/crates/fluke-curl-tests/tests/integration_test.rs
index 1a79610b..261a8a8c 100644
--- a/crates/fluke-curl-tests/tests/integration_test.rs
+++ b/crates/fluke-curl-tests/tests/integration_test.rs
@@ -1,7 +1,7 @@
 mod helpers;
 
 use bytes::BytesMut;
-use fluke::buffet::{ChanRead, ChanWrite, IntoHalves};
+use fluke::buffet::{IntoHalves, ReadOwned, WriteOwned};
 use fluke::{
     buffet::{PieceCore, RollMut},
     h1, h2, Body, BodyChunk, Encoder, ExpectResponseHeaders, Headers, HeadersExt, Method, Request,
@@ -12,10 +12,8 @@ use httparse::{Status, EMPTY_HEADER};
 use pretty_assertions::assert_eq;
 use pretty_hex::PrettyHex;
 use std::{future::Future, net::SocketAddr, process::Command, rc::Rc, time::Duration};
-use tokio::{
-    io::{AsyncReadExt, AsyncWriteExt},
-    net::TcpStream,
-};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
 use tracing::debug;
 
 mod proxy;
@@ -82,17 +80,30 @@ fn serve_api() {
             }
         }
 
-        let (tx, read) = ChanRead::new();
-        let (mut rx, write) = ChanWrite::new();
+        let (mut client_write, server_read) = fluke::buffet::pipe();
+        let (server_write, mut client_read) = fluke::buffet::pipe();
         let client_buf = RollMut::alloc()?;
         let driver = TestDriver;
-        let serve_fut = fluke::buffet::spawn(h1::serve((read, write), conf, client_buf, driver));
-
-        tx.send("GET / HTTP/1.1\r\n\r\n").await?;
+        let serve_fut = fluke::buffet::spawn(h1::serve(
+            (server_read, server_write),
+            conf,
+            client_buf,
+            driver,
+        ));
+
+        client_write
+            .write_all_owned("GET / HTTP/1.1\r\n\r\n")
+            .await?;
         let mut res_buf = BytesMut::new();
-        while let Some(chunk) = rx.recv().await {
+        let mut buf = vec![0u8; 1024];
+        loop {
+            let res;
+            (res, buf) = client_read.read_owned(buf).await;
+            let n = res.unwrap();
+            let chunk = &buf[..n];
+
             debug!("Got a chunk:\n{:?}", chunk.hex_dump());
-            res_buf.extend_from_slice(&chunk[..]);
+            res_buf.extend_from_slice(chunk);
 
             let mut headers = [EMPTY_HEADER; 16];
             let mut res = httparse::Response::new(&mut headers[..]);
@@ -119,7 +130,7 @@ fn serve_api() {
             }
         }
 
-        drop(tx);
+        drop(client_write);
 
         tokio::time::timeout(Duration::from_secs(5), serve_fut).await???;
 
@@ -130,8 +141,8 @@ fn serve_api() {
 #[test]
 fn request_api() {
     helpers::run(async move {
-        let (tx, read) = ChanRead::new();
-        let (mut rx, write) = ChanWrite::new();
+        let (mut server_write, client_read) = fluke::buffet::pipe();
+        let (client_write, mut server_read) = fluke::buffet::pipe();
 
         let req = Request {
             method: Method::Get,
@@ -171,13 +182,18 @@ fn request_api() {
         let request_fut = fluke::buffet::spawn(async {
             #[allow(clippy::let_unit_value)]
             let mut body = ();
-            h1::request((read, write), req, &mut body, driver).await
+            h1::request((client_read, client_write), req, &mut body, driver).await
         });
 
         let mut req_buf = BytesMut::new();
-        while let Some(chunk) = rx.recv().await {
+        let mut buf = vec![0u8; 1024];
+        loop {
+            let res;
+            (res, buf) = server_read.read_owned(buf).await;
+            let n = res.unwrap();
+            let chunk = &buf[..n];
             debug!("Got a chunk:\n{:?}", chunk.hex_dump());
-            req_buf.extend_from_slice(&chunk[..]);
+            req_buf.extend_from_slice(chunk);
 
             let mut headers = [EMPTY_HEADER; 16];
             let mut req = httparse::Request::new(&mut headers[..]);
@@ -197,12 +213,13 @@ fn request_api() {
 
         let body = "Hi there";
         let body_len = body.len();
-        tx.send(format!(
-            "HTTP/1.1 200 OK\r\ncontent-length: {body_len}\r\n\r\n"
-        ))
-        .await?;
-        tx.send(body).await?;
-        drop(tx);
+        server_write
+            .write_all_owned(
+                format!("HTTP/1.1 200 OK\r\ncontent-length: {body_len}\r\n\r\n").into_bytes(),
+            )
+            .await?;
+        server_write.write_all_owned(body).await?;
+        drop(server_write);
 
         tokio::time::timeout(Duration::from_secs(5), request_fut).await???;
 
@@ -222,7 +239,7 @@ fn proxy_statuses() {
         for status in [200, 400, 500] {
             debug!("Asking for a {status}");
             socket
-                .write_all(format!("GET /status/{status} HTTP/1.1\r\n\r\n").as_bytes())
+                .write_all_owned(format!("GET /status/{status} HTTP/1.1\r\n\r\n").into_bytes())
                 .await?;
 
             socket.flush().await?;
@@ -283,14 +300,14 @@ fn proxy_echo_body_content_len() {
 
         let send_fut = async move {
             write
-                .write_all(
+                .write_all_owned(
                     format!("POST /echo-body HTTP/1.1\r\ncontent-length: {content_len}\r\n\r\n")
-                        .as_bytes(),
+                        .into_bytes(),
                 )
                 .await?;
             write.flush().await?;
 
-            write.write_all(body.as_bytes()).await?;
+            write.write_all_owned(body.as_bytes()).await?;
             write.flush().await?;
 
             Ok::<(), eyre::Report>(())
@@ -384,7 +401,9 @@ fn proxy_echo_body_chunked() {
 
         let send_fut = async move {
             write
-                .write_all(b"POST /echo-body HTTP/1.1\r\ntransfer-encoding: chunked\r\n\r\n")
+                .write_all_owned(
+                    &b"POST /echo-body HTTP/1.1\r\ntransfer-encoding: chunked\r\n\r\n"[..],
+                )
                 .await?;
             write.flush().await?;
 
@@ -392,12 +411,12 @@ fn proxy_echo_body_chunked() {
             for chunk in chunks {
                 let chunk_len = chunk.len();
                 write
-                    .write_all(format!("{chunk_len:x}\r\n{chunk}\r\n").as_bytes())
+                    .write_all_owned(format!("{chunk_len:x}\r\n{chunk}\r\n").into_bytes())
                     .await?;
                 write.flush().await?;
             }
 
-            write.write_all(b"0\r\n\r\n").await?;
+            write.write_all_owned(&b"0\r\n\r\n"[..]).await?;
             write.flush().await?;
 
             Ok::<(), eyre::Report>(())
diff --git a/crates/fluke/tests/httpwg.rs b/crates/fluke/tests/httpwg.rs
new file mode 100644
index 00000000..517f2a04
--- /dev/null
+++ b/crates/fluke/tests/httpwg.rs
@@ -0,0 +1,116 @@
+use std::rc::Rc;
+
+use fluke::{Body, Encoder, ExpectResponseHeaders, Responder, Response, ResponseDone};
+use fluke_buffet::{IntoHalves, PipeRead, PipeWrite, ReadOwned, RollMut, WriteOwned};
+use http::StatusCode;
+use tracing::Level;
+use tracing_subscriber::{filter::Targets, layer::SubscriberExt, util::SubscriberInitExt};
+
+/// Note: this will not work with `cargo test`, since it sets up process-level
+/// globals. But it will work with `cargo nextest`, and that's what fluke is standardizing on.
+pub(crate) fn setup_tracing_and_error_reporting() {
+    color_eyre::install().unwrap();
+
+    let targets = if let Ok(rust_log) = std::env::var("RUST_LOG") {
+        rust_log.parse::<Targets>().unwrap()
+    } else {
+        Targets::new()
+            .with_default(Level::INFO)
+            .with_target("fluke", Level::DEBUG)
+            .with_target("httpwg", Level::DEBUG)
+            .with_target("want", Level::INFO)
+    };
+
+    let fmt_layer = tracing_subscriber::fmt::layer()
+        .with_ansi(true)
+        .with_file(false)
+        .with_line_number(false)
+        .without_time();
+
+    tracing_subscriber::registry()
+        .with(targets)
+        .with(fmt_layer)
+        .init();
+}
+
+struct TestDriver;
+
+impl fluke::ServerDriver for TestDriver {
+    async fn handle<E: Encoder>(
+        &self,
+        _req: fluke::Request,
+        _req_body: &mut impl Body,
+        mut res: Responder<E, ExpectResponseHeaders>,
+    ) -> eyre::Result<Responder<E, ResponseDone>> {
+        let mut buf = RollMut::alloc()?;
+
+        buf.put(b"Continue")?;
+
+        res.write_interim_response(Response {
+            status: StatusCode::CONTINUE,
+            ..Default::default()
+        })
+        .await?;
+
+        buf.put(b"OK")?;
+
+        _ = buf;
+
+        let res = res
+            .write_final_response(Response {
+                status: StatusCode::OK,
+                ..Default::default()
+            })
+            .await?;
+
+        let res = res.finish_body(None).await?;
+
+        Ok(res)
+    }
+}
+
+pub struct TwoHalves<W, R>(W, R);
+impl<W: WriteOwned, R: ReadOwned> IntoHalves for TwoHalves<W, R> {
+    type Read = R;
+    type Write = W;
+
+    fn into_halves(self) -> (Self::Read, Self::Write) {
+        (self.1, self.0)
+    }
+}
+
+pub fn start_server() -> httpwg::Conn<TwoHalves<PipeWrite, PipeRead>> {
+    let (server_write, client_read) = fluke::buffet::pipe();
+    let (client_write, server_read) = fluke::buffet::pipe();
+
+    let serve_fut = async move {
+        let server_conf = Rc::new(fluke::h2::ServerConf {
+            ..Default::default()
+        });
+
+        let client_buf = RollMut::alloc()?;
+        let driver = Rc::new(TestDriver);
+        let io = (server_read, server_write);
+        fluke::h2::serve(io, server_conf, client_buf, driver).await?;
+        tracing::debug!("http/2 server done");
+        Ok::<_, eyre::Report>(())
+    };
+
+    fluke_buffet::spawn(async move {
+        serve_fut.await.unwrap();
+    });
+
+    let config = Rc::new(httpwg::Config::default());
+    httpwg::Conn::new(config, TwoHalves(client_write, client_read))
+}
+
+#[cfg(test)]
+httpwg_macros::tests! {{
+   crate::setup_tracing_and_error_reporting();
+
+   fluke_buffet::start(async move {
+       let conn = crate::start_server();
+       let result = test(conn).await;
+       result.unwrap()
+   });
+}}