| Author | Andy Green <andy@warmcat.com> 2026-10-05 12:39 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 13:01 UTC | | Tree | 4abf6e16326962a5dcce95a0d31cda579623e4c4 Raw Patch | | | npro-test: npro's h1 client replays C's client transcripts | npro-test: npro's h1 client replays C's client transcripts
The seven h1 client transcripts that need nothing beyond phase 1d (not
the digest retry, which needs the digest and lws' random): npro's client
makes C's request, GET /x or HEAD /x to sansio with C's defaults, and
must write the transcript's request byte for byte; after each rx, give
the app the transcript's app_rx bytes; ask to be released exactly where
C released the connection, for a Content-Length of "10abc", two of
them, or a list of codings; and otherwise have completed the
transaction, so that a HEAD's, or a 304's, framing headers are not
waited on.
And h1-ws-server's first exchange, a GET to the sansio vhost whose http
protocol answers "sansio ok", replays through npro's server: the rest of
it is ws, for phase 1e.
The 304 taken to have a body, or "Connection" spelled as the request is
not, fails it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
|
diff --git a/crates/npro-test/tests/h1_client_replay.rs b/crates/npro-test/tests/h1_client_replay.rs
new file mode 100644
index 0000000..7c183c5
--- /dev/null
+++ b/crates/npro-test/tests/h1_client_replay.rs
@@ -0,0 +1,129 @@
+//! npro's h1 client replays C's h1 client transcripts.
+//!
+//! Each is one request C's `api-test-sansio` makes, `GET /x` (or `HEAD /x`)
+//! to `sansio`, port 80, with C's defaults: no-cache headers, an `Origin`,
+//! and `connection: close`. The request npro writes must be the
+//! transcript's first `tx`, byte for byte; after each `rx`, the body npro
+//! gives the app must be the transcript's `app_rx` bytes, and npro must ask
+//! to release the connection exactly where C released it, its `close`.
+//! Where C did not, its transaction was complete at the end, and npro's
+//! must be.
+
+#![expect(
+ unused_crate_dependencies,
+ reason = "an integration test sees all of its crate's dependencies; this one uses npro-test and npro-h1"
+)]
+
+// held to clippy's rules for tests
+#[cfg(test)]
+mod h1_client_replay {
+ use npro_h1::client::{Client, Event, Request, Scheme};
+ use npro_h1::table::DEFAULT_CAPACITY;
+ use npro_test::{StepKind, Transcript, vendored};
+
+ /// The h1 client transcripts that need no more of lws than this phase
+ /// has: not the digest retry, which needs the digest and lws' random.
+ const CASES: [&str; 7] = [
+ "h1-client-get",
+ "h1-client-cl-junk",
+ "h1-client-cl-twice",
+ "h1-client-te-list",
+ "h1-client-head-chunked",
+ "h1-client-head-cl",
+ "h1-client-304-cl",
+ ];
+
+ /// Hands `input` to the client until it takes nothing more; returns
+ /// the body it gave the app, and whether it failed.
+ fn feed(c: &mut Client<Vec<u8>>, mut input: &[u8]) -> (Vec<u8>, bool) {
+ let mut body = Vec::new();
+ loop {
+ let Ok(rx) = c.rx(input) else {
+ return (body, true);
+ };
+ input = &input[rx.consumed..];
+ match rx.event {
+ Some(Event::Body(b)) => body.extend_from_slice(b),
+ None if rx.consumed == 0 => return (body, false),
+ Some(Event::Response | Event::BodyEnd) | None => {}
+ }
+ }
+ }
+
+ fn replay(t: &Transcript) {
+ let mut steps = t.steps.iter().peekable();
+ let Some(StepKind::Tx(want)) = steps.next().map(|s| &s.kind) else {
+ panic!("{}: does not start with the request", t.case);
+ };
+ let method: &[u8] = if want.starts_with(b"HEAD ") {
+ b"HEAD"
+ } else {
+ b"GET"
+ };
+ let mut c = Client::new(
+ vec![0u8; DEFAULT_CAPACITY],
+ Request {
+ method,
+ path: b"/x",
+ host: Some(b"sansio"),
+ origin: Some(b"sansio"),
+ scheme: Scheme::Http,
+ no_cache: true,
+ pipeline: false,
+ },
+ )
+ .unwrap();
+ let mut out = [0u8; 512];
+ let n = c.tx(&mut out);
+ assert_eq!(
+ out[..n].escape_ascii().to_string(),
+ want.escape_ascii().to_string(),
+ "{}: the request",
+ t.case
+ );
+
+ while let Some(step) = steps.next() {
+ let StepKind::Rx(rx) = &step.kind else {
+ panic!("{}: {:?} with no rx before it", t.case, step.kind);
+ };
+ let (mut want_body, mut want_close) = (Vec::new(), false);
+ while let Some(next) = steps.peek() {
+ match &next.kind {
+ StepKind::AppRx(b) => want_body.extend_from_slice(b),
+ StepKind::Close => want_close = true,
+ StepKind::Rx(_) => break,
+ StepKind::Tx(b) => panic!("{}: a second request {b:?}", t.case),
+ }
+ steps.next();
+ }
+ let (body, failed) = feed(&mut c, rx);
+ assert_eq!(body, want_body, "{} at {}us: the body", t.case, step.t_us);
+ assert_eq!(
+ failed,
+ want_close,
+ "{} at {}us: released, {:?}",
+ t.case,
+ step.t_us,
+ c.failed()
+ );
+ }
+ // where C did not release it, it had completed the transaction:
+ // nothing is left waiting for a body that will not come
+ if c.failed().is_none() {
+ assert!(c.is_done(), "{}: not done", t.case);
+ }
+ }
+
+ #[test]
+ #[cfg_attr(miri, ignore = "reads the transcripts: native runs keep it")]
+ fn cs_client_transcripts_replay() {
+ let all = vendored().unwrap();
+ for case in CASES {
+ let t = all
+ .iter()
+ .find(|t| t.case == case)
+ .unwrap_or_else(|| panic!("no transcript {case}"));
+ replay(t);
+ }
+ }
+}
diff --git a/crates/npro-test/tests/h1_server_replay.rs b/crates/npro-test/tests/h1_server_replay.rs
index 28129a5..abaea6e 100644
--- a/crates/npro-test/tests/h1_server_replay.rs
+++ b/crates/npro-test/tests/h1_server_replay.rs
@@ -7,6 +7,7 @@
//! with a length of 10 and 3 bytes (none to a HEAD); `/body-done` from the
//! body's first piece, with `ok\n`. Each `rx` is handed to npro's server,
//! and what it writes before the next must be the transcript's `tx` bytes.
+//! So must `h1-ws-server`'s first exchange, a GET to the `sansio` vhost.
#![expect(
unused_crate_dependencies,
@@ -58,6 +59,8 @@ mod h1_server_replay {
complete_when_sent: bool,
/// `/body-done`: answer from the body.
body_done: bool,
+ /// The `sansio` vhost's `http` instead: this, to any request.
+ fixed: Option<&'static [u8]>,
}
impl TxSource for UriApp {
@@ -84,6 +87,11 @@ mod h1_server_replay {
}
fn request(&mut self, s: &mut Server<Vec<u8>>) {
+ if let Some(f) = self.fixed {
+ let len = u64::try_from(f.len()).unwrap();
+ self.answer(s, len, f);
+ return;
+ }
let t = s.request();
let path = t
.first(Token::GetUri)
@@ -146,14 +154,15 @@ mod h1_server_replay {
}
}
- fn replay(t: &Transcript) {
- let cfg = head::Config::new()
- .with_limit(Token::GetUri, NonZeroU16::new(33).unwrap())
- .with_limit(Token::UserAgent, NonZeroU16::new(16).unwrap());
+ /// Replays `t`'s first `rxs` reads, with `app`, in a context limiting
+ /// what `cfg` limits.
+ fn replay(t: &Transcript, cfg: head::Config, mut app: UriApp, rxs: usize) {
let mut s = Server::new(vec![0u8; DEFAULT_CAPACITY], Config::new(cfg)).unwrap();
- let mut app = UriApp::default();
let mut steps = t.steps.iter().peekable();
- while let Some(step) = steps.next() {
+ for _ in 0..rxs {
+ let Some(step) = steps.next() else {
+ break;
+ };
let StepKind::Rx(rx) = &step.kind else {
panic!("{}: {:?} with no rx before it", t.case, step.kind);
};
@@ -188,7 +197,24 @@ mod h1_server_replay {
.iter()
.find(|t| t.case == case)
.unwrap_or_else(|| panic!("no transcript {case}"));
- replay(t);
+ let cfg = head::Config::new()
+ .with_limit(Token::GetUri, NonZeroU16::new(33).unwrap())
+ .with_limit(Token::UserAgent, NonZeroU16::new(16).unwrap());
+ replay(t, cfg, UriApp::default(), usize::MAX);
}
}
+
+ /// `h1-ws-server`'s first exchange, a GET to the `sansio` vhost, whose
+ /// `http` answers `sansio ok`; the rest of it is ws.
+ #[test]
+ #[cfg_attr(miri, ignore = "reads the transcripts: native runs keep it")]
+ fn h1_ws_servers_first_exchange_replays() {
+ let all = vendored().unwrap();
+ let t = all.iter().find(|t| t.case == "h1-ws-server").unwrap();
+ let app = UriApp {
+ fixed: Some(b"sansio ok\n"),
+ ..UriApp::default()
+ };
+ replay(t, head::Config::new(), app, 1);
+ }
}
|