diff --git a/Cargo.lock b/Cargo.lock index fd8a556c..70a5406f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -662,6 +662,7 @@ version = "0.0.4" dependencies = [ "clap", "log", + "mq-h3", "mq-linux", "mq-proxy", "mq-runtime", diff --git a/crates/mq-h3/src/conn.rs b/crates/mq-h3/src/conn.rs index 478db536..27f551f9 100644 --- a/crates/mq-h3/src/conn.rs +++ b/crates/mq-h3/src/conn.rs @@ -74,7 +74,7 @@ impl H3Wire { self.service(now, c); } - /// An inner event, active mode. Returns it if it passes through; the raw stream events + /// An inner event. Returns it if it passes through; the raw stream events /// of H3 conns are consumed here (adoption spec §4.1). pub(crate) fn on_event(&mut self, now: Time, e: Event) -> Option { match e { diff --git a/crates/mq-h3/src/lib.rs b/crates/mq-h3/src/lib.rs index 42b6a734..d1619402 100644 --- a/crates/mq-h3/src/lib.rs +++ b/crates/mq-h3/src/lib.rs @@ -1,6 +1,6 @@ //! `H3Wire`: a `TransportOps` decorator serving HTTP/3 from h3wire (adoption spec §4.1). //! -//! Active mode (`H3Wire::new`) runs one h3wire `Connection` per H3 conn and serves the +//! Runs one h3wire `Connection` per H3 conn and serves the //! requests from it. #![forbid(unsafe_code)] @@ -26,8 +26,6 @@ pub const BOOT_READ: usize = 4096; pub struct H3Wire { inner: T, - /// `false`: create no H3 state; every op and event passes through (adoption spec §4.1). - active: bool, queue: OutQueue, /// Request streams of H3 conns, by request id (adoption spec §4.3). reqs: HashMap, @@ -49,20 +47,10 @@ pub struct H3Wire { } impl H3Wire { - /// Serves H3 conns from h3wire (inner must be `h3_backend: Raw`). + /// Serves H3 conns over the raw transport. pub fn new(inner: T) -> Self { - Self::make(inner, true) - } - - /// Creates no H3 state: every op and event passes through unfiltered (inner may be xqc_h3). - pub fn passthrough(inner: T) -> Self { - Self::make(inner, false) - } - - fn make(inner: T, active: bool) -> Self { H3Wire { inner, - active, queue: OutQueue::default(), reqs: HashMap::new(), conns: HashMap::new(), @@ -141,11 +129,7 @@ impl H3Wire { self.service(now, c); } while let Some(e) = self.inner.poll_event() { - let out = if self.active { - self.on_event(now, e) - } else { - Some(e) - }; + let out = self.on_event(now, e); if let Some(e) = out { self.queue.push(e); } @@ -192,11 +176,7 @@ impl TransportOps for H3Wire { /// stale between that drain and this pop, so the same checks run again here. fn poll_event(&mut self) -> Option { let Self { - inner, - active, - queue, - reqs, - .. + inner, queue, reqs, .. } = self; queue.pop(|e| match e { Event::ConnEstablished(c) @@ -209,9 +189,6 @@ impl TransportOps for H3Wire { | Event::StreamWritable(s) | Event::StreamPeerReset(s, _) | Event::StreamStopSending(s, _) => inner.stream_info(*s).is_ok(), - Event::H3Request(_, r) | Event::H3Readable(r) | Event::H3Writable(r) if !*active => { - inner.h3_req_info(*r).is_ok() - } // An H3Readable after the receive end was handed over has nothing to deliver. Event::H3Readable(r) => reqs.get(r).is_some_and(|q| !q.handed), Event::H3Request(_, r) | Event::H3Writable(r) => reqs.contains_key(r), @@ -228,7 +205,7 @@ impl TransportOps for H3Wire { fn connect(&mut self, now: Time, cfg: &ConnConfig) -> Result { let r = self.inner.connect(now, cfg); - if let (true, ConnProto::H3, Ok(c)) = (self.active, cfg.proto, r) { + if let (ConnProto::H3, Ok(c)) = (cfg.proto, r) { self.add_conn(now, c, h3wire::Role::Client); } self.drive_inner(now); @@ -314,13 +291,10 @@ impl TransportOps for H3Wire { self.inner.datagram_recv(conn, buf) } - // H3 ops. Active: served from h3wire, never forwarded; an id not held is stale + // H3 ops are served from h3wire; an id not held is stale // (adoption spec §4.1). fn open_h3_request(&mut self, now: Time, conn: ConnId) -> Result { - if self.active { - return self.open_req(now, conn); - } - fwd!(self, now, self.inner.open_h3_request(now, conn)) + self.open_req(now, conn) } fn h3_send_headers( @@ -330,11 +304,8 @@ impl TransportOps for H3Wire { hs: &[H3Header<'_>], fin: bool, ) -> Result<(), StreamError> { - if self.active { - self.touch(r); - return self.send_headers(now, r, hs, fin); - } - fwd!(self, now, self.inner.h3_send_headers(now, r, hs, fin)) + self.touch(r); + self.send_headers(now, r, hs, fin) } fn h3_send_body( @@ -344,19 +315,13 @@ impl TransportOps for H3Wire { data: &[u8], fin: bool, ) -> Result { - if self.active { - self.touch(r); - return self.send_body(now, r, data, fin); - } - fwd!(self, now, self.inner.h3_send_body(now, r, data, fin)) + self.touch(r); + self.send_body(now, r, data, fin) } fn h3_finish(&mut self, now: Time, r: H3ReqId) -> Result<(), StreamError> { - if self.active { - self.touch(r); - return self.finish(now, r); - } - fwd!(self, now, self.inner.h3_finish(now, r)) + self.touch(r); + self.finish(now, r) } fn h3_recv_headers( @@ -365,11 +330,8 @@ impl TransportOps for H3Wire { r: H3ReqId, each: &mut dyn FnMut(&[u8], &[u8]), ) -> Result { - if self.active { - self.touch(r); - return self.recv_headers(now, r, each); - } - fwd!(self, now, self.inner.h3_recv_headers(now, r, each)) + self.touch(r); + self.recv_headers(now, r, each) } fn h3_recv_body( @@ -382,32 +344,23 @@ impl TransportOps for H3Wire { { self.max_buf = self.max_buf.max(buf.len()); } - if self.active { - self.touch(r); - return self.recv_body(now, r, buf); - } - fwd!(self, now, self.inner.h3_recv_body(now, r, buf)) + self.touch(r); + self.recv_body(now, r, buf) } fn h3_reset(&mut self, now: Time, r: H3ReqId) { - if self.active { - self.touch(r); - return self.reset(now, r); - } - fwd!(self, now, self.inner.h3_reset(now, r)) + self.touch(r); + self.reset(now, r) } fn h3_req_info(&self, r: H3ReqId) -> Result { - if self.active { - // Cached at request start: the inner slot is gone before StreamClosed. - return match self.reqs.get(&r) { - Some(q) if q.known => Ok(H3ReqInfo { - conn: q.conn, - quic_id: q.quic_id, - }), - _ => Err(Error::Stale), - }; + // Cached at request start: the inner slot is gone before StreamClosed. + match self.reqs.get(&r) { + Some(q) if q.known => Ok(H3ReqInfo { + conn: q.conn, + quic_id: q.quic_id, + }), + _ => Err(Error::Stale), } - self.inner.h3_req_info(r) } } diff --git a/crates/mq-h3/src/recv.rs b/crates/mq-h3/src/recv.rs index 74477725..96a9221d 100644 --- a/crates/mq-h3/src/recv.rs +++ b/crates/mq-h3/src/recv.rs @@ -431,7 +431,6 @@ impl H3Wire { if req.known { let close = H3Close { stats: req.stats(conn_err), - unread: None, }; self.queue.push(Event::H3Closed(id, Box::new(close))); } diff --git a/crates/mq-h3/src/req.rs b/crates/mq-h3/src/req.rs index 8e03cd61..180a8237 100644 --- a/crates/mq-h3/src/req.rs +++ b/crates/mq-h3/src/req.rs @@ -91,6 +91,9 @@ pub(crate) struct Req { pub(crate) stream_closed: bool, /// The DATA frame `h3_send_body` started and has not written whole (adoption spec §4.5). pub(crate) frame: Option, + /// HEADERS were sent with `fin=true`; the raw FIN rides their final bytes, so + /// suppress h3wire's corresponding empty `FinishStream` action. + pub(crate) coalesced_header_fin: bool, /// `h3_finish` (or a bare `fin`) is waiting for `send_data(0, true)` to stop being /// `Blocked` behind queued HEADERS bytes. pub(crate) finish_latched: bool, @@ -135,6 +138,7 @@ impl Req { fin_read: false, stream_closed: false, frame: None, + coalesced_header_fin: false, finish_latched: false, writable_wanted: false, send_stopped: false, diff --git a/crates/mq-h3/src/send.rs b/crates/mq-h3/src/send.rs index 378150d1..cce0663d 100644 --- a/crates/mq-h3/src/send.rs +++ b/crates/mq-h3/src/send.rs @@ -95,7 +95,7 @@ impl H3Wire { /// Client request start (adoption spec §4.3): the request exists from `open_stream`; /// h3wire holds state for it once `send_headers` succeeds. pub(crate) fn open_req(&mut self, now: Time, c: ConnId) -> Result { - // The xqc_h3 backend's order (`reserve_local`): role, conn, protocol. + // Request admission order: role, conn, protocol. let r = match self.conns.get(&c) { _ if self.server => Err(Error::Role), None if self.inner.conn_stats(c).is_ok() => Err(Error::Other), // a raw conn @@ -137,6 +137,7 @@ impl H3Wire { let r = conn.h3.send_headers(Q(req.quic_id), &fields, fin); req.core |= r.is_ok(); if r.is_ok() { + req.coalesced_header_fin = fin; conn.core.insert(req.quic_id); } self.drive_inner(now); @@ -185,7 +186,7 @@ impl H3Wire { // A retained request (client only) accepts and discards like after SendStopped, // so a running upload pump cannot abort the response (adoption spec §4.3). if req.send_stopped || self.conns[&req.conn].gone { - // RFC 9114 §4.1: the client's response keeps flowing; xqc_h3 resets the server. + // RFC 9114 §4.1: the client's response keeps flowing after cancelling the upload. return if client { Ok(data.len()) } else { @@ -283,7 +284,7 @@ impl H3Wire { } /// `h3_finish`: latches the finish intent and returns `Ok` while HEADERS bytes are - /// queued, as xqc_h3 does (adoption spec §4.5). + /// queued (adoption spec §4.5). pub(crate) fn finish(&mut self, now: Time, id: H3ReqId) -> Result<(), StreamError> { let client = self.client_of(id)?; let r = self.body(now, id, &[], true, client).map(drop); @@ -402,10 +403,17 @@ impl H3Wire { // A `Blocked` FIN is kept and retried; it survives a peer RESET_STREAM // (adoption spec §3, §4.5). Action::FinishStream(q) => { - if let Some(s) = mq(q) - && self.inner.stream_send(now, s, &[], true) == Err(StreamError::Blocked) - { - conn.fins.insert(s); + if let Some(s) = mq(q) { + let headers_fin = self + .reqs + .get_mut(&req_id(s)) + .is_some_and(|req| std::mem::take(&mut req.coalesced_header_fin)); + if !headers_fin + && self.inner.stream_send(now, s, &[], true) + == Err(StreamError::Blocked) + { + conn.fins.insert(s); + } } } // Direction guard (adoption spec §4.5): the transport ops do not check. @@ -456,8 +464,9 @@ impl H3Wire { let mut req = reqs.get_mut(&req_id(s)).filter(|_| q.is_request()); while let Some(bytes) = conn.h3.poll_send(q).filter(|b| !b.is_empty()) { let len = bytes.len(); + let fin = req.as_ref().is_some_and(|req| req.coalesced_header_fin); // Another error is final for the stream: no retry owed. - let n = match inner.stream_send(now, s, bytes, false) { + let n = match inner.stream_send(now, s, bytes, fin) { Ok(n) => n, Err(e) => { short |= e == StreamError::Blocked; diff --git a/crates/mq-h3/tests/common/mod.rs b/crates/mq-h3/tests/common/mod.rs index 6dc062b0..ac48484d 100644 --- a/crates/mq-h3/tests/common/mod.rs +++ b/crates/mq-h3/tests/common/mod.rs @@ -345,8 +345,8 @@ impl Rig { } } - /// A RESET_STREAM on `q` from a peer stack that resets whatever its h3 state (e.g. - /// xqc_h3, even after its FIN); the h3wire peer is not told and its later bytes on `q` + /// A RESET_STREAM on `q` from a peer stack that resets even after its FIN; + /// the h3wire peer is not told and its later bytes on `q` /// are dropped. pub fn peer_raw_reset(&mut self, q: u64, code: u64) { let Some(s) = self.stream_of(q) else { diff --git a/crates/mq-h3/tests/lifecycle.rs b/crates/mq-h3/tests/lifecycle.rs index e885dff9..e343301b 100644 --- a/crates/mq-h3/tests/lifecycle.rs +++ b/crates/mq-h3/tests/lifecycle.rs @@ -77,14 +77,11 @@ fn started(r: &mut Rig) -> H3ReqId { ids[0] } -/// The `H3Closed` ids in `ev`, each asserting `unread: None`. +/// The `H3Closed` ids in `ev`, with their close stats. fn closed(ev: &[Event]) -> Vec { ev.iter() .filter_map(|e| match e { - Event::H3Closed(id, c) => { - assert_eq!(c.unread, None); - Some(*id) - } + Event::H3Closed(id, _) => Some(*id), _ => None, }) .collect() diff --git a/crates/mq-h3/tests/passthrough.rs b/crates/mq-h3/tests/passthrough.rs deleted file mode 100644 index d7c29005..00000000 --- a/crates/mq-h3/tests/passthrough.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Pass-through behaviour of `H3Wire` over `ScriptedTransport` (adoption spec §4.1). - -use mq_h3::H3Wire; -use mq_runtime::testing::{Call, ScriptedHandle, ScriptedTransport}; -use mq_transport_api::{ - CloseReason, ConnConfig, ConnProto, ErrType, Error, Event, H3Close, H3ReqStats, StreamError, - StreamInfo, StreamKind, Time, TransportOps, -}; -use std::net::SocketAddr; - -fn mk(active: bool) -> (H3Wire, ScriptedHandle) { - let (t, h) = ScriptedTransport::new(); - ( - if active { - H3Wire::new(t) - } else { - H3Wire::passthrough(t) - }, - h, - ) -} - -const NOW: Time = Time::ZERO; - -fn addr() -> SocketAddr { - "127.0.0.1:4433".parse().unwrap() -} - -fn raw_ops(active: bool) { - let (mut w, h) = mk(active); - let cfg = ConnConfig { - peer: addr(), - sni: "mqproxy", - idle_timeout: None, - proto: ConnProto::Raw, - }; - let c = w.connect(NOW, &cfg).unwrap(); - let s = w.open_stream(NOW, c).unwrap(); - assert_eq!(w.stream_send(NOW, s, b"abc", true), Ok(3)); - let mut buf = [0u8; 8]; - assert_eq!(w.stream_recv(NOW, s, &mut buf), Err(StreamError::Blocked)); - w.stream_reset(NOW, s); - w.stream_reset_send(NOW, s, 0x10c); - w.stream_stop_sending(NOW, s, 0x10b); - let u = w.open_uni(NOW, c).unwrap(); - assert_eq!(w.add_path(NOW, c, true).map(|_| ()), Ok(())); - assert_eq!(w.datagram_send(NOW, c, b"dg"), Ok(())); - w.close_conn(NOW, c); - w.close_conn_with(NOW, c, 0x101); - let _ = w.conn_stats(c); - let _ = w.stream_info(s); - let log = h.log(); - let want = [ - Call::Connect(cfg), - Call::OpenStream(c), - Call::StreamSend { - s, - bytes: b"abc".to_vec(), - fin: true, - }, - Call::StreamRecv { s, cap: 8 }, - Call::StreamReset(s), - Call::StreamResetSend { s, code: 0x10c }, - Call::StreamStopSending { s, code: 0x10b }, - Call::OpenUni(c), - Call::AddPath { - conn: c, - standby: true, - }, - Call::DatagramSend { - conn: c, - bytes: b"dg".to_vec(), - }, - Call::CloseConn(c), - Call::CloseConnWith { - conn: c, - code: 0x101, - }, - ]; - assert_eq!(log, want); - let _ = u; -} - -#[test] -fn raw_ops_reach_inner() { - raw_ops(false); - raw_ops(true); -} - -fn raw_events(active: bool) { - let (mut w, h) = mk(active); - let c = h.new_conn_id(); - let s = h.new_stream_id(); - let info = StreamInfo { - conn: c, - quic_id: 0, - kind: StreamKind::Bidi, - }; - h.set_stream_info(s, info); - h.set_conn_stats(c, Default::default()); // the conn is live - let reason = CloseReason { - err_type: ErrType::Unknown, - code: 0, - }; - let evs = vec![ - Event::NewConn(c, ConnProto::Raw), - Event::ConnEstablished(c), - Event::NewStream(c, s, info), - Event::StreamReadable(s), - Event::StreamWritable(s), - Event::StreamPeerReset(s, 0x10c), - Event::StreamStopSending(s, 0x10b), - Event::MpReady(c), - Event::DatagramReadable(c), - Event::StreamClosed(s), - Event::ConnClosed(c, reason), - ]; - for e in &evs { - h.push_event(e.clone()); - } - w.drive(NOW); - let got: Vec<_> = std::iter::from_fn(|| w.poll_event()).collect(); - assert_eq!(got, evs); -} - -#[test] -fn raw_events_pop_out() { - raw_events(false); - raw_events(true); -} - -#[test] -fn passthrough_h3_ops_and_events() { - let (mut w, h) = mk(false); - let c = h.new_conn_id(); - let r = h.new_h3_request(c); - h.expect_open_h3_request(c, Ok(r)); - assert_eq!(w.open_h3_request(NOW, c), Ok(r)); - assert_eq!(w.h3_send_body(NOW, r, b"x", false), Ok(1)); - assert_eq!(w.h3_finish(NOW, r), Ok(())); - let mut buf = [0u8; 4]; - assert_eq!(w.h3_recv_body(NOW, r, &mut buf), Err(StreamError::Blocked)); - assert_eq!( - w.h3_recv_headers(NOW, r, &mut |_, _| {}), - Err(StreamError::Blocked) - ); - w.h3_reset(NOW, r); - let log = h.log(); - for call in [ - Call::OpenH3Request(c), - Call::H3Finish(r), - Call::H3Reset(r), - Call::H3RecvHeaders(r), - ] { - assert!(log.contains(&call), "{call:?} missing in {log:?}"); - } - assert!(log.contains(&Call::H3RecvBody { r, cap: 4 })); - - // Events for ids the wrapper never saw pass unchanged. - let r2 = h.new_h3_request(c); - let stats = H3ReqStats { - send_body: 0, - recv_body: 0, - begin_us: 0, - header_send_us: 0, - fin_send_us: 0, - fin_ack_us: 0, - mp_state: 0, - stream_err: 0, - close_msg: None, - }; - let closed = Event::H3Closed( - r2, - Box::new(H3Close { - stats, - unread: None, - }), - ); - h.push_event(Event::H3Readable(r2)); - h.push_event(Event::H3Writable(r2)); - h.push_event(closed.clone()); - w.drive(NOW); - let got: Vec<_> = std::iter::from_fn(|| w.poll_event()).collect(); - assert_eq!( - got, - vec![ - Event::H3Request(c, r), - Event::H3Request(c, r2), - Event::H3Readable(r2), - Event::H3Writable(r2), - closed - ] - ); -} - -#[test] -fn active_unknown_id_is_stale_and_not_forwarded() { - let (mut w, h) = mk(true); - let c = h.new_conn_id(); - let r = h.new_h3_request(c); - let mut buf = [0u8; 4]; - assert_eq!(w.h3_recv_body(NOW, r, &mut buf), Err(StreamError::Stale)); - assert_eq!(w.h3_send_body(NOW, r, b"x", false), Err(StreamError::Stale)); - assert_eq!(w.h3_finish(NOW, r), Err(StreamError::Stale)); - assert_eq!(w.h3_req_info(r), Err(Error::Stale)); - w.h3_reset(NOW, r); - let log = h.log(); - assert!( - !log.iter().any(|c| matches!( - c, - Call::H3RecvBody { .. } - | Call::H3SendBody { .. } - | Call::H3Finish(_) - | Call::H3Reset(_) - )), - "forwarded: {log:?}" - ); -} - -/// Pops are filtered as the bare transport filters them (adoption spec §4.1): in passthrough, -/// an H3 event whose request the inner transport no longer knows is dropped; in both modes a -/// readiness or creation event of a gone conn is dropped. Close events always pop. -#[test] -fn stale_on_pop_dropped_like_bare_transport() { - for active in [false, true] { - let (mut w, h) = mk(active); - let (live, gone) = (h.new_conn_id(), h.new_conn_id()); - h.set_conn_stats(live, Default::default()); - let r = h.new_h3_request(live); - h.inject_h3_headers(r, vec![(b":method".to_vec(), b"GET".to_vec())], true); - h.push_event(Event::MpReady(gone)); - h.push_event(Event::DatagramReadable(gone)); - h.push_event(Event::MpReady(live)); - w.drive(NOW); // drained while everything was still live in the inner queue - let close = H3Close { - stats: H3ReqStats { - send_body: 0, - recv_body: 0, - begin_us: 0, - header_send_us: 0, - fin_send_us: 0, - fin_ack_us: 0, - mp_state: 0, - stream_err: 0, - close_msg: None, - }, - unread: None, - }; - h.close_h3(r, close.clone()); // the request goes stale between drain and pop - let got: Vec<_> = std::iter::from_fn(|| w.poll_event()).collect(); - assert_eq!(got, vec![Event::MpReady(live)], "active={active}"); - w.drive(NOW); - let got: Vec<_> = std::iter::from_fn(|| w.poll_event()).collect(); - assert_eq!( - got, - vec![Event::H3Closed(r, Box::new(close))], - "active={active}" - ); - } -} - -/// The liveness check of a conn-level pop is the cheap `conn_live`, not `conn_stats` (which -/// collects path stats on xquic): after the cutover the UDP lane pops these constantly. -#[test] -fn conn_pops_do_not_collect_stats() { - for active in [false, true] { - let (mut w, h) = mk(active); - let (live, gone) = (h.new_conn_id(), h.new_conn_id()); - h.set_conn_stats(live, Default::default()); - h.push_event(Event::DatagramReadable(live)); - h.push_event(Event::MpReady(gone)); - w.drive(NOW); - let before = h.conn_stats_calls(); - let got: Vec<_> = std::iter::from_fn(|| w.poll_event()).collect(); - assert_eq!(got, [Event::DatagramReadable(live)], "active={active}"); - assert_eq!( - h.conn_stats_calls(), - before, - "conn_stats probed, active={active}" - ); - } -} diff --git a/crates/mq-h3/tests/recv.rs b/crates/mq-h3/tests/recv.rs index c114fbda..edbd9aaa 100644 --- a/crates/mq-h3/tests/recv.rs +++ b/crates/mq-h3/tests/recv.rs @@ -383,11 +383,11 @@ fn reset_code_pending_then_stream_closed() { .filter(|e| matches!(e, Event::H3Closed(..))) .collect(); assert_eq!(closed.len(), 1, "{ev:?}"); - let Event::H3Closed(cid, close) = closed[0] else { + let Event::H3Closed(cid, _) = closed[0] else { unreachable!() }; assert_eq!(*cid, id); - assert_eq!(close.unread, None); + assert_eq!(aborts(&r, s), [], "actions on the gone stream are dropped"); // The id is stale from now on. let now = r.now; @@ -461,7 +461,7 @@ fn abort_in_feed_retires() { ); } -/// The xqc_h3 backend's order (`reserve_local`): role, then conn, then protocol. +/// Request admission order: role, then conn, then protocol. #[test] fn open_h3_request_errors() { let mut r = Rig::client(); @@ -536,7 +536,7 @@ fn oversized_headers_closes() { } /// The MITM's terminal probe `h3_recv_body(id, &mut [])` (mq-proxy `client/mitm/stream.rs`) -/// answers as on xqc_h3: `Blocked` while payload or nothing is left, `(0, true)` once only +/// answers: `Blocked` while payload or nothing is left, `(0, true)` once only /// the FIN is. The FIN comes alone here: the probe reads it into the empty carry and feeds /// it as a bare carried FIN (a carried FIN never outlives a feed otherwise: a slice passes it /// with the whole carry). @@ -559,8 +559,7 @@ fn empty_probe_bare_fin() { } /// Framing alone (an empty DATA frame) left before the FIN: the probe feeds no byte (slices -/// of at most `buf.len()`, adoption spec §4.4) and stays `Blocked`, where xqc_h3, which parsed -/// the frame on arrival, answers `(0, true)`. Open against the spec (final-fix report, I3); +/// of at most `buf.len()`, adoption spec §4.4) and stays `Blocked`; /// the next read with a buffer ends the body. #[test] fn empty_probe_framing_before_fin() { diff --git a/crates/mq-h3/tests/send.rs b/crates/mq-h3/tests/send.rs index 1c2f5ed6..495da96f 100644 --- a/crates/mq-h3/tests/send.rs +++ b/crates/mq-h3/tests/send.rs @@ -175,6 +175,121 @@ fn client_request_round_trip() { assert_eq!(body, b"hello"); } +#[test] +fn headers_fin_rides_final_header_bytes() { + let mut r = client(); + let (id, s) = open(&mut r); + assert_eq!( + r.w.h3_send_headers(r.now, id, &request("GET"), true), + Ok(()) + ); + let fin_calls: Vec<_> = + r.h.log() + .into_iter() + .filter_map(|c| match c { + Call::StreamSend { + s: sent, + bytes, + fin: true, + } if sent == s => Some(bytes), + _ => None, + }) + .collect(); + assert_eq!(fin_calls.len(), 1); + assert!(!fin_calls[0].is_empty(), "FIN should ride HEADERS bytes"); + assert_eq!(r.fins(s), 1); + r.pump(); + assert_peer_finished(&r, Q(0)); +} + +#[test] +fn headers_fin_survives_partial_write_once() { + let mut r = client(); + let (id, s) = open(&mut r); + r.limit(s, Some(1)); + assert_eq!( + r.w.h3_send_headers(r.now, id, &request("GET"), true), + Ok(()) + ); + assert_eq!(r.h.sent_bytes(s).len(), 1); + assert_eq!(r.fins(s), 0, "partial headers must not commit FIN"); + + r.limit(s, None); + r.h.push_event(Event::StreamWritable(s)); + r.w.drive(r.now); + assert_eq!(r.fins(s), 1); + let fin_calls: Vec<_> = + r.h.log() + .into_iter() + .filter_map(|c| match c { + Call::StreamSend { + s: sent, + bytes, + fin: true, + } if sent == s => Some(bytes), + _ => None, + }) + .collect(); + assert_eq!( + fin_calls.len(), + 2, + "the unfinished header tail retries with FIN" + ); + assert!(fin_calls.iter().all(|bytes| !bytes.is_empty())); + r.pump(); + assert_peer_finished(&r, Q(0)); + assert_eq!(r.fins(s), 1, "the retry commits exactly one FIN"); +} + +#[test] +fn headers_fin_retries_after_blocked_write_once() { + let mut r = client(); + let (id, s) = open(&mut r); + r.limit(s, Some(0)); + assert_eq!( + r.w.h3_send_headers(r.now, id, &request("GET"), true), + Ok(()) + ); + assert!(r.h.sent_bytes(s).is_empty()); + assert_eq!(r.fins(s), 0, "a blocked write cannot commit FIN"); + let blocked: Vec<_> = + r.h.log() + .into_iter() + .filter_map(|c| match c { + Call::StreamSend { + s: sent, + bytes, + fin: true, + } if sent == s => Some(bytes), + _ => None, + }) + .collect(); + assert_eq!(blocked.len(), 1); + assert!(!blocked[0].is_empty(), "the blocked call carries HEADERS"); + + r.limit(s, None); + r.h.push_event(Event::StreamWritable(s)); + r.w.drive(r.now); + assert_eq!(r.fins(s), 1); + let fin_calls: Vec<_> = + r.h.log() + .into_iter() + .filter_map(|c| match c { + Call::StreamSend { + s: sent, + bytes, + fin: true, + } if sent == s => Some(bytes), + _ => None, + }) + .collect(); + assert_eq!(fin_calls.len(), 2, "one blocked attempt, one retry"); + assert!(fin_calls.iter().all(|bytes| !bytes.is_empty())); + r.pump(); + assert_peer_finished(&r, Q(0)); + assert_eq!(r.fins(s), 1, "the retry commits exactly one FIN"); +} + #[test] fn send_body_partial_payload() { let (mut r, id, s, head) = posting(); @@ -505,7 +620,7 @@ fn invalid_field_maps_to_reset() { assert!(r.h.sent_bytes(s).is_empty()); } -/// A peer RESET_STREAM after the request's FIN (xqc_h3 sends one on any cancel): h3wire +/// A peer RESET_STREAM after the request's FIN (a peer cancels its stream): h3wire /// resets our send side but emits no `StreamAborted` after `Finished`, so it ends the send /// side as `SendStopped` does, here with a DATA frame in flight. #[test] diff --git a/crates/mq-h3/tests/stats.rs b/crates/mq-h3/tests/stats.rs index 9ba8f920..80e5a429 100644 --- a/crates/mq-h3/tests/stats.rs +++ b/crates/mq-h3/tests/stats.rs @@ -106,7 +106,6 @@ fn stats_clean() { stream_err: 0, close_msg: Some("finished".into()), }, - unread: None, } )] ); diff --git a/crates/mq-integration/Cargo.toml b/crates/mq-integration/Cargo.toml index 9263d4ca..01918bca 100644 --- a/crates/mq-integration/Cargo.toml +++ b/crates/mq-integration/Cargo.toml @@ -36,7 +36,7 @@ mq-transport = { path = "../mq-transport" } mq-transport-api = { path = "../mq-transport-api" } mq-linux = { path = "../mq-linux" } mq-wire = { path = "../mq-wire", optional = true } -# The h3wire backend (loopback::Backend::Wire) and the raw-H3 peer's byte builders (raw_h3.rs). +# The h3wire transport (loopback::h3_transport) and the raw-H3 peer's byte builders (raw_h3.rs). mq-h3 = { path = "../mq-h3", optional = true, features = ["test-support"] } h3wire = { path = "../../third_party/h3wire/h3wire", optional = true } log = { workspace = true, optional = true } diff --git a/crates/mq-integration/src/h3_apps.rs b/crates/mq-integration/src/h3_apps.rs index 50a30068..206cc7af 100644 --- a/crates/mq-integration/src/h3_apps.rs +++ b/crates/mq-integration/src/h3_apps.rs @@ -6,12 +6,12 @@ use mq_runtime::{ AcceptMeta, App, Cx, DialError, DialOpId, ListenerTag, SocketOpId, TcpEnd, TcpId, TimerId, UdpSocketId, }; -use mq_transport_api::{ConnConfig, ConnId, ConnProto, Event, H3Close, H3Header, H3ReqId}; +use mq_transport_api::{ConnConfig, ConnProto, Event, H3Close, H3Header, H3ReqId}; use std::collections::HashMap; use std::io; use std::net::SocketAddr; use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::{Duration, Instant}; +use std::time::Instant; /// What an app observed. #[derive(Debug, Default)] @@ -26,10 +26,6 @@ pub struct H3Recorded { pub request_headers: Vec<(Vec, Vec)>, /// Every `H3Closed`, with its arrival time. pub closed: Vec<(H3Close, Instant)>, - /// `ResetBeforeFin` / `CloseConnMidResponse`: when the server cut the response, and the - /// connection's srtt then. - pub cut_at: Option, - pub srtt_us: u64, } /// `Clone + Send` view of an app's `H3Recorded`. @@ -116,19 +112,12 @@ pub enum EchoMode { /// The request body + FIN. #[default] Echo, - /// The request body without FIN, then (100 ms later) `h3_reset`. - ResetBeforeFin, - /// The request body without FIN, then (100 ms later) `close_conn` (CONNECTION_CLOSE 0). - CloseConnMidResponse, /// `content-length: cl` over a complete `sent`-byte DATA frame + FIN. ShortCl { cl: u64, sent: usize }, } -const CUT_DELAY: Duration = Duration::from_millis(100); - #[derive(Default)] struct EchoReq { - conn: Option, body: Vec, resp: Vec, sent: usize, @@ -143,7 +132,6 @@ pub struct H3EchoServer { resp_headers: Vec<(String, String)>, h: H3Handle, reqs: HashMap, - timers: HashMap, } impl H3EchoServer { @@ -154,7 +142,6 @@ impl H3EchoServer { resp_headers: Vec::new(), h: h.clone(), reqs: HashMap::new(), - timers: HashMap::new(), }; (app, h) } @@ -175,10 +162,6 @@ impl H3EchoServer { let cl = |n: u64| ("content-length".to_owned(), n.to_string()); match self.mode { EchoMode::Echo => (q.resp, q.fin) = (std::mem::take(&mut q.body), true), - EchoMode::ResetBeforeFin | EchoMode::CloseConnMidResponse => { - q.resp = std::mem::take(&mut q.body); - self.timers.insert(cx.set_timer(CUT_DELAY), r); - } EchoMode::ShortCl { cl: n, sent } => { hs.push(cl(n)); (q.resp, q.fin) = (vec![b'x'; sent], true); @@ -189,37 +172,16 @@ impl H3EchoServer { push(cx, r, &q.resp, &mut q.sent, q.fin); } } - - fn cut(&mut self, cx: &mut Cx<'_>, r: H3ReqId) { - let Some(conn) = self.reqs.get(&r).and_then(|q| q.conn) else { - return; - }; - let srtt = cx - .conn_stats(conn) - .map_or(0, |s| s.paths.iter().map(|p| p.srtt_us).max().unwrap_or(0)); - { - let mut rec = self.h.lock(); - rec.cut_at = Some(Instant::now()); - rec.srtt_us = srtt; - } - if self.mode == EchoMode::ResetBeforeFin { - cx.h3_reset(r); - } else { - cx.close_conn(conn); - } - } } impl App for H3EchoServer { + fn on_timer(&mut self, _: &mut Cx<'_>, _: TimerId) {} fn on_start(&mut self, _: &mut Cx<'_>) {} fn on_transport_event(&mut self, cx: &mut Cx<'_>, ev: Event) { match ev { - Event::H3Request(c, r) => { - let q = EchoReq { - conn: Some(c), - ..EchoReq::default() - }; + Event::H3Request(_, r) => { + let q = EchoReq::default(); self.reqs.insert(r, q); self.h.lock().requests += 1; } @@ -250,12 +212,6 @@ impl App for H3EchoServer { } } - fn on_timer(&mut self, cx: &mut Cx<'_>, id: TimerId) { - if let Some(r) = self.timers.remove(&id) { - self.cut(cx, r); - } - } - no_io!(); } @@ -265,9 +221,6 @@ pub struct H3Script { pub headers: Vec<(String, String)>, /// Sent with FIN (on the headers when empty). pub body: Vec, - /// On the first `H3Readable`, read nothing for this long (reading the FIN would set - /// `fin_consumed` and nothing would be rescued, spec §3.3), then read everything. - pub pause_reads: Option, } /// An `App` that connects (H3) to `peer` on start, sends its one scripted request once the @@ -278,7 +231,6 @@ pub struct H3Client { h: H3Handle, req: Option, sent: usize, - held: bool, } impl H3Client { @@ -290,7 +242,6 @@ impl H3Client { h: h.clone(), req: None, sent: 0, - held: false, }; (app, h) } @@ -329,25 +280,13 @@ impl App for H3Client { self.send(cx, r); } Event::H3Writable(r) => self.send(cx, r), - Event::H3Readable(r) => { - if let Some(d) = self.s.pause_reads.take() { - self.held = true; - cx.set_timer(d); - } else if !self.held { - self.read(cx, r); - } - } + Event::H3Readable(r) => self.read(cx, r), Event::H3Closed(_, close) => self.h.lock().closed.push((*close, Instant::now())), _ => {} } } - fn on_timer(&mut self, cx: &mut Cx<'_>, _: TimerId) { - self.held = false; - if let Some(r) = self.req { - self.read(cx, r); - } - } + fn on_timer(&mut self, _: &mut Cx<'_>, _: TimerId) {} no_io!(); } diff --git a/crates/mq-integration/src/loopback.rs b/crates/mq-integration/src/loopback.rs index d555fc61..a3e6ece6 100644 --- a/crates/mq-integration/src/loopback.rs +++ b/crates/mq-integration/src/loopback.rs @@ -15,9 +15,7 @@ use mq_runtime::{ TcpId, TimerId, UdpSocketId, }; use mq_transport::Transport; -use mq_transport_api::{ - CongestionControl, Event, H3Backend, Role, Scheduler, TransportConfig, TransportOps, -}; +use mq_transport_api::{CongestionControl, Event, Role, Scheduler, TransportConfig, TransportOps}; use std::io; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -34,7 +32,7 @@ pub struct LoopbackPair { pub client: DriverThread, /// `restart_server`'s factory (pairs built by `spawn_mitm`). respawn: Option>, - /// `H3Request`s the server app was handed (`spawn_gateway_on` pairs; 0 elsewhere): what + /// `H3Request`s the server app was handed (`spawn_gateway` pairs; 0 elsewhere): what /// the gateway saw of the requests, whatever it logged. pub server_h3_requests: Arc, } @@ -168,95 +166,8 @@ impl LoopbackPair { /// (`client.listen_addrs[0]`) and an HTTP CONNECT (`[1]`) listener. pub type LoopbackProxy = LoopbackPair<(), ()>; -/// A real transport as the proxy runs it (test cert, ALPN "mqproxy-tcp/1", BBR/MinRtt); `h3` -/// registers the H3 ctx. +/// A real transport with optional raw ALPN `h3` enabled. pub fn transport(role: Role, h3: bool) -> Transport { - transport_on(role, h3, H3Backend::XqcH3) -} - -/// `transport(role, true)` on `h3_backend: Raw`: H3 conns get raw streams and no H3 stack -/// (adoption spec §3). -pub fn raw_h3_transport(role: Role) -> Transport { - transport_on(role, true, H3Backend::Raw) -} - -/// Which H3 stack serves one endpoint (adoption spec §6.2). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Backend { - /// xquic's xqc_h3, behind a passthrough `H3Wire`. - XqcH3, - /// h3wire, in an active `H3Wire` over `h3_backend: Raw`. - Wire, - /// No H3 stack: a passthrough `H3Wire` over `h3_backend: Raw`, for a `RawH3Peer` side. - Raw, -} - -/// The differential matrix (adoption spec §6.2): `matrix!(name, |cells: (Backend, Backend), -/// tag: &str| body)` runs `body` once per (client, server) backend cell, as the tests -/// `name::{xx, xw, wx, ww}`; `tag` is the cell's name. -#[macro_export] -macro_rules! matrix { - ($name:ident, |$cells:ident: (Backend, Backend), $tag:ident: &str| $body:block $(,)?) => { - mod $name { - use super::*; - use $crate::loopback::Backend::{Wire, XqcH3}; - - fn body($cells: ($crate::loopback::Backend, $crate::loopback::Backend), $tag: &str) - $body - - #[test] - fn xx() { - body((XqcH3, XqcH3), "xx") - } - #[test] - fn xw() { - body((XqcH3, Wire), "xw") - } - #[test] - fn wx() { - body((Wire, XqcH3), "wx") - } - #[test] - fn ww() { - body((Wire, Wire), "ww") - } - } - }; -} - -/// Malformed input against each receiver (adoption spec §6.2): `receivers!(name, |b: Backend, -/// tag: &str| body)` runs `body` as the tests `name::{x, w}`, with `b` the receiving side's -/// backend (`XqcH3`, `Wire`) and `tag` the cell's name; a `RawH3Peer` is the other side. -#[macro_export] -macro_rules! receivers { - ($name:ident, |$b:ident: Backend, $tag:ident: &str| $body:block $(,)?) => { - mod $name { - use super::*; - - fn body($b: $crate::loopback::Backend, $tag: &str) $body - - #[test] - fn x() { - body($crate::loopback::Backend::XqcH3, "x") - } - #[test] - fn w() { - body($crate::loopback::Backend::Wire, "w") - } - } - }; -} - -/// An H3 transport on backend `b` (adoption spec §6.2). -pub fn h3_transport(role: Role, b: Backend) -> H3Wire { - match b { - Backend::XqcH3 => H3Wire::passthrough(transport(role, true)), - Backend::Wire => H3Wire::new(raw_h3_transport(role)), - Backend::Raw => H3Wire::passthrough(raw_h3_transport(role)), - } -} - -fn transport_on(role: Role, h3: bool, h3_backend: H3Backend) -> Transport { Transport::new(TransportConfig { role, alpn: "mqproxy-tcp/1", @@ -265,12 +176,21 @@ fn transport_on(role: Role, h3: bool, h3_backend: H3Backend) -> Transport { cc: CongestionControl::Bbr, realtime_offset_us: 0, h3, - h3_backend, qlog: None, }) .expect("transport") } +/// A raw transport for a peer that writes HTTP/3 bytes directly. +pub fn raw_h3_transport(role: Role) -> Transport { + transport(role, true) +} + +/// HTTP/3 over the raw transport. +pub fn h3_transport(role: Role) -> H3Wire { + H3Wire::new(raw_h3_transport(role)) +} + /// `tests/certs/`. pub fn cert(name: &str) -> PathBuf { PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/certs")).join(name) @@ -322,24 +242,11 @@ impl LoopbackProxy { self.client.listen_addrs[1] } - /// The gateway pair (spec §5, §6): the fetch client against `Server::with_gateway`, H3 - /// on both transports. The FETCH listener is the client's only listener (no TCP ingress, - /// so no raw tunnel); `origin_ca` is the bridge's only trust root. A `None` gateway in - /// either config is filled with a placeholder / `GatewayConfig::default()`. + /// The fetch client against the production gateway over HTTP/3. pub fn spawn_gateway( - client: ClientConfig, - server: ServerConfig, - origin_ca: &Path, - ) -> LoopbackProxy { - Self::spawn_gateway_on(client, server, origin_ca, (Backend::XqcH3, Backend::XqcH3)) - } - - /// `spawn_gateway` with the (client, server) H3 backends (adoption spec §6.2). - pub fn spawn_gateway_on( client: ClientConfig, mut server: ServerConfig, origin_ca: &Path, - backends: (Backend, Backend), ) -> LoopbackProxy { server.gateway.get_or_insert_with(GatewayConfig::default); let tls = build_client_config(Some(origin_ca), &Vec::new).expect("origin CA"); @@ -350,33 +257,24 @@ impl LoopbackProxy { app: Server::with_gateway(server, tls), n: n2, }; - let mut p = Self::gateway_pair(client, app, backends); + let mut p = Self::gateway_pair(client, app, || h3_transport(server_role())); p.server_h3_requests = n; p } - /// The fetch client of `spawn_gateway` against an arbitrary H3 server `App` (a - /// malformed peer). - pub fn spawn_gateway_against( - client: ClientConfig, - server_app: A, - ) -> LoopbackProxy { - Self::spawn_gateway_against_on(client, server_app, (Backend::XqcH3, Backend::XqcH3)) - } - - /// `spawn_gateway_against` with the (client, server) H3 backends (adoption spec §6.2). - pub fn spawn_gateway_against_on( + /// The fetch client against a supplied server app and transport. + pub fn spawn_gateway_against( client: ClientConfig, server_app: A, - backends: (Backend, Backend), + transport: impl FnOnce() -> T + Send + 'static, ) -> LoopbackProxy { - Self::gateway_pair(client, move || server_app, backends) + Self::gateway_pair(client, move || server_app, transport) } - fn gateway_pair( + fn gateway_pair( mut client: ClientConfig, server_app: impl FnOnce() -> A + Send + 'static, - (client_b, server_b): (Backend, Backend), + transport: impl FnOnce() -> T + Send + 'static, ) -> LoopbackProxy { // The bound address is what `fetch_addr` reports; `Client::new` only needs `Some`. client @@ -388,18 +286,12 @@ impl LoopbackProxy { (lo, lo), vec![(ListenKind::Plain, FETCH)], move |local| { - let t = h3_transport( - Role::Server { - cert: cert("test.crt"), - key: cert("test.key"), - }, - server_b, - ); + let t = transport(); (Shard::new(t, server_app(), local, 1), ()) }, move |local, server_udp| { client.server = server_udp; - let t = h3_transport(Role::Client, client_b); + let t = h3_transport(Role::Client); (Shard::new(t, Client::new(client), local, 2), ()) }, ) @@ -409,42 +301,23 @@ impl LoopbackProxy { self.client.listen_addrs[0] } - /// The MITM pair (SP4 spec §7, §11.3): `client.mitm = mitm` on one `TRANSPARENT` - /// listener whose accepts all carry `fixed_dst` as their original destination (R3), - /// against `Server::with_gateway` (`origin_ca` its only trust root); H3 on both - /// transports. The client keeps its raw tunnel for the opaque relay. + /// A transparent MITM client against the production gateway, with server respawn support. pub fn spawn_mitm( - client: ClientConfig, - server: ServerConfig, - origin_ca: &Path, - mitm: MitmConfig, - fixed_dst: SocketAddr, - ) -> LoopbackProxy { - let cells = (Backend::XqcH3, Backend::XqcH3); - Self::spawn_mitm_on(client, server, origin_ca, mitm, fixed_dst, cells) - } - - /// `spawn_mitm` with the (client, server) H3 backends (adoption spec §6.2). - pub fn spawn_mitm_on( mut client: ClientConfig, mut server: ServerConfig, origin_ca: &Path, mitm: MitmConfig, fixed_dst: SocketAddr, - (client_b, server_b): (Backend, Backend), ) -> LoopbackProxy { server.gateway.get_or_insert_with(GatewayConfig::default); let tls = build_client_config(Some(origin_ca), &Vec::new).expect("origin CA"); let respawn = move |udp: SocketAddr| { let (server, tls) = (server.clone(), tls.clone()); DriverThread::spawn_on_addr(udp, driver_config(), Vec::new(), move |local| { - let t = h3_transport( - Role::Server { - cert: cert("test.crt"), - key: cert("test.key"), - }, - server_b, - ); + let t = h3_transport(Role::Server { + cert: cert("test.crt"), + key: cert("test.key"), + }); let app = Server::with_gateway(server, tls); (Shard::new(t, app, local, 1), ()) }) @@ -457,7 +330,7 @@ impl LoopbackProxy { vec![(ListenKind::Fixed(fixed_dst), TRANSPARENT)], move |local, server_udp| { client.server = server_udp; - let t = h3_transport(Role::Client, client_b); + let t = h3_transport(Role::Client); (Shard::new(t, Client::new(client), local, 2), ()) }, ); @@ -481,3 +354,11 @@ impl LoopbackProxy { self.server.start(); } } + +/// The loopback test certificate and key. +pub fn server_role() -> Role { + Role::Server { + cert: cert("test.crt"), + key: cert("test.key"), + } +} diff --git a/crates/mq-integration/src/raw_h3.rs b/crates/mq-integration/src/raw_h3.rs index c125dfe8..3be31331 100644 --- a/crates/mq-integration/src/raw_h3.rs +++ b/crates/mq-integration/src/raw_h3.rs @@ -45,7 +45,7 @@ pub struct RawH3Script { } /// A peer that speaks HTTP/3 by writing fixed bytes on raw streams (spec §6.2): no H3 stack, -/// so it can send what no compliant stack sends. Runs on `Transport` with `h3_backend: Raw` +/// so it can send what no compliant stack sends. Runs on the raw `Transport` with ALPN `h3` enabled /// (`loopback::raw_h3_transport`). pub struct RawH3Peer { /// Client role: the server to connect to. diff --git a/crates/mq-integration/src/shard_pair.rs b/crates/mq-integration/src/shard_pair.rs index 794979ef..ba5dc464 100644 --- a/crates/mq-integration/src/shard_pair.rs +++ b/crates/mq-integration/src/shard_pair.rs @@ -75,7 +75,6 @@ pub fn transport_cfg(role: Role, max_conns: u32) -> TransportConfig { cc: CongestionControl::Bbr, realtime_offset_us: 0, h3: false, - h3_backend: mq_transport_api::H3Backend::XqcH3, qlog: None, } } diff --git a/crates/mq-integration/tests/loop_engine_boot.rs b/crates/mq-integration/tests/loop_engine_boot.rs index e97c412e..1196f7cb 100644 --- a/crates/mq-integration/tests/loop_engine_boot.rs +++ b/crates/mq-integration/tests/loop_engine_boot.rs @@ -26,7 +26,6 @@ fn cfg(role: Role) -> TransportConfig { cc: CongestionControl::Bbr, realtime_offset_us: 0, h3: false, - h3_backend: mq_transport_api::H3Backend::XqcH3, qlog: None, } } diff --git a/crates/mq-integration/tests/pair_gateway.rs b/crates/mq-integration/tests/pair_gateway.rs index ae450b80..4dd7158f 100644 --- a/crates/mq-integration/tests/pair_gateway.rs +++ b/crates/mq-integration/tests/pair_gateway.rs @@ -7,17 +7,14 @@ use mq_integration::driver_harness::DriverThread; use mq_integration::h3_apps::{EchoMode, H3Client, H3EchoServer, H3Handle, H3Recorded, H3Script}; use mq_integration::log_tap; -use mq_integration::loopback::{Backend, LoopbackProxy, h3_transport}; +use mq_integration::loopback::{LoopbackProxy, h3_transport, raw_h3_transport, server_role}; use mq_integration::origin_server::{Handler, ORIGIN_CA, OriginServer, OriginServerMode, Proto}; -use mq_integration::raw_h3::{ - RawH3Handle, RawH3Peer, RawH3Script, data_frame, field, headers_frame, -}; -use mq_integration::{matrix, receivers}; +use mq_integration::raw_h3::{RawH3Handle, RawH3Peer, RawH3Script, data_frame, headers_frame}; use mq_proxy::config::{ClientConfig, GatewayConfig, ServerConfig}; use mq_proxy::server::origin::host::upload_byte; use mq_runtime::driver::{DriverConfig, StdResolver}; use mq_runtime::{App, Shard}; -use mq_transport_api::{CloseReason, ErrType, Role}; +use mq_transport_api::{CloseReason, ErrType, Role, TransportOps}; use std::io::{self, Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::path::Path; @@ -41,15 +38,15 @@ fn server_cfg() -> ServerConfig { } } -/// The gateway pair on the (client, server) backends `cells` (adoption spec §6.2). -fn gateway_with(server: ServerConfig, cells: (Backend, Backend)) -> LoopbackProxy { +/// The gateway pair over HTTP/3. +fn gateway_with(server: ServerConfig) -> LoopbackProxy { log_tap::install(); let client = ClientConfig::default(); - LoopbackProxy::spawn_gateway_on(client, server, Path::new(ORIGIN_CA), cells) + LoopbackProxy::spawn_gateway(client, server, Path::new(ORIGIN_CA)) } -fn gateway(cells: (Backend, Backend)) -> LoopbackProxy { - gateway_with(server_cfg(), cells) +fn gateway() -> LoopbackProxy { + gateway_with(server_cfg()) } /// Polls `cond` every 5 ms until it holds (at most `T`). @@ -120,11 +117,6 @@ fn req_line(path: &str) -> String { wait_log(&["mq.req", &format!("path=\"{path}\"")]) } -/// The cell's index (xx 0, xw 1, wx 2, ww 3), for a marker unique to the cell. -fn cell_index((c, s): (Backend, Backend)) -> usize { - 2 * usize::from(c == Backend::Wire) + usize::from(s == Backend::Wire) -} - // ---- the local fetch caller ---- /// Sends `POST /_mqproxy/fetch` with `headers` and `body` (written on its own thread, so a @@ -258,10 +250,9 @@ fn pattern(len: usize) -> Vec { // ---- direct H3 ---- -/// The client app `make()` builds, on its own driver on backend `b`. -fn client_on( - b: Backend, - make: impl FnOnce() -> (A, H) + Send + 'static, +/// The app and transport `make()` builds, on their own driver. +fn client_on( + make: impl FnOnce() -> (T, A, H) + Send + 'static, ) -> DriverThread { let cfg = DriverConfig { resolver: Arc::new(StdResolver), @@ -270,22 +261,26 @@ fn client_on( }; let lo = Ipv4Addr::LOCALHOST.into(); let mut d = DriverThread::spawn_on(lo, cfg, Vec::new(), move |local| { - let (app, h) = make(); - (Shard::new(h3_transport(Role::Client, b), app, local, 3), h) + let (t, app, h) = make(); + (Shard::new(t, app, local, 3), h) }); d.start(); d } -/// An `H3Client` on backend `b` against the gateway server. -fn h3_client(server: SocketAddr, s: H3Script, b: Backend) -> DriverThread { - client_on(b, move || H3Client::new(server, s)) +/// An `H3Client` against the gateway server. +fn h3_client(server: SocketAddr, s: H3Script) -> DriverThread { + client_on(move || { + let (app, h) = H3Client::new(server, s); + (h3_transport(Role::Client), app, h) + }) } /// A `RawH3Peer` client writing `stream` + FIN to the gateway server. fn raw_client(server: SocketAddr, stream: Vec) -> DriverThread { - client_on(Backend::Raw, move || { - RawH3Peer::client(server, RawH3Script { stream, fin: true }) + client_on(move || { + let (app, h) = RawH3Peer::client(server, RawH3Script { stream, fin: true }); + (raw_h3_transport(Role::Client), app, h) }) } @@ -312,11 +307,7 @@ fn script( .chain(extra) .map(|(n, v)| ((*n).into(), (*v).into())) .collect(); - H3Script { - headers, - body, - ..H3Script::default() - } + H3Script { headers, body } } fn bearer() -> String { @@ -474,88 +465,87 @@ fn capture_one(mut tcp: TcpStream) -> Seen { // ---- fetch API end to end ---- // spec §5.4, §6.4, §10.3: an 8 MiB download with `content-length` over an h2 TLS origin. -matrix!( - fetch_download_8mib_cl, - |cells: (Backend, Backend), tag: &str| { - let len = 8 * MIB; - let o = OriginServer::spawn(OriginServerMode::new( - Proto::H2Tls, - Handler::FileBytes(len as u64), - )); - let p = gateway(cells); - wait_up(&p, tag); - let url = format!("https://127.0.0.1:{}/dl-{tag}", o.addr.port()); - let r = parse(&fetch(&p, &url, &[], b"").expect("download")); - let want = [ - ("x-mq-origin-protocol", "h2"), - ("date", "T"), - ("content-length", "8388608"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert_eq!(r.status, 200); - assert_eq!(r.header("content-length"), Some("8388608")); - assert!( - r.body == pattern(len), - "body differs ({} bytes)", - r.body.len() - ); - let path = format!("/dl-{tag}"); - let a = format!("127.0.0.1:{}", o.addr.port()); - let want = format!( - "sid=4 method=GET status=200 authority=\"{a}\" path=\"{path}\" req_bytes=0 resp_bytes=8388608 ttfb_ms=T duration_ms=T origin_protocol=h2 origin_tls=ok content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"" - ); - assert_req(&req_line(&path), &want); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn fetch_download_8mib_cl() { + let tag = "ww"; + let len = 8 * MIB; + let o = OriginServer::spawn(OriginServerMode::new( + Proto::H2Tls, + Handler::FileBytes(len as u64), + )); + let p = gateway(); + wait_up(&p, tag); + let url = format!("https://127.0.0.1:{}/dl-{tag}", o.addr.port()); + let r = parse(&fetch(&p, &url, &[], b"").expect("download")); + let want = [ + ("x-mq-origin-protocol", "h2"), + ("date", "T"), + ("content-length", "8388608"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert_eq!(r.status, 200); + assert_eq!(r.header("content-length"), Some("8388608")); + assert!( + r.body == pattern(len), + "body differs ({} bytes)", + r.body.len() + ); + let path = format!("/dl-{tag}"); + let a = format!("127.0.0.1:{}", o.addr.port()); + let want = format!( + "sid=4 method=GET status=200 authority=\"{a}\" path=\"{path}\" req_bytes=0 resp_bytes=8388608 ttfb_ms=T duration_ms=T origin_protocol=h2 origin_tls=ok content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"" + ); + assert_req(&req_line(&path), &want); + assert_eq!(p.join_both(), (0, 0)); +} // spec §5.3, §6.3, §10.3: an 8 MiB `PUT` upload, streamed back by an h1 echo origin. -matrix!( - fetch_upload_8mib_put, - |cells: (Backend, Backend), tag: &str| { - let o = OriginServer::spawn(OriginServerMode::new(Proto::H1Plain, Handler::Echo)); - let p = gateway(cells); - wait_up(&p, tag); - let body = pattern(8 * MIB); - let url = format!("http://{}/up-{tag}", o.addr); - let r = parse(&fetch(&p, &url, &[("X-Mq-Method", "PUT")], &body).expect("upload")); - let want = [ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "8388608"), - ("date", "T"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert_eq!(r.status, 200); - assert_eq!(r.header("content-length"), Some("8388608")); - assert!(r.body == body, "echo differs ({} bytes)", r.body.len()); - let path = format!("/up-{tag}"); - let want = format!( - "sid=4 method=PUT status=200 authority=\"{}\" path=\"{path}\" req_bytes=8388608 resp_bytes=8388608 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", - o.addr - ); - assert_req(&req_line(&path), &want); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn fetch_upload_8mib_put() { + let tag = "ww"; + let o = OriginServer::spawn(OriginServerMode::new(Proto::H1Plain, Handler::Echo)); + let p = gateway(); + wait_up(&p, tag); + let body = pattern(8 * MIB); + let url = format!("http://{}/up-{tag}", o.addr); + let r = parse(&fetch(&p, &url, &[("X-Mq-Method", "PUT")], &body).expect("upload")); + let want = [ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "8388608"), + ("date", "T"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert_eq!(r.status, 200); + assert_eq!(r.header("content-length"), Some("8388608")); + assert!(r.body == body, "echo differs ({} bytes)", r.body.len()); + let path = format!("/up-{tag}"); + let want = format!( + "sid=4 method=PUT status=200 authority=\"{}\" path=\"{path}\" req_bytes=8388608 resp_bytes=8388608 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", + o.addr + ); + assert_req(&req_line(&path), &want); + assert_eq!(p.join_both(), (0, 0)); +} // spec §5.4, §5.5, §10.3: `content-length: 100` over a complete 50-byte DATA frame + FIN -// from a peer: the local socket is aborted. On an xqc_h3 client its own body check does it; a -// `Wire` client's h3wire finds the malformed response (RFC 9114 §4.1.2) and aborts the +// from a peer: the local socket is aborted. h3wire finds the malformed response +// (RFC 9114 §4.1.2) and aborts the // stream, which reaches the client as `Err(Reset)`. -matrix!( - fetch_short_cl_response_aborts_client, - |cells: (Backend, Backend), tag: &str| { - let (app, h) = H3EchoServer::new(EchoMode::ShortCl { cl: 100, sent: 50 }); - let p = LoopbackProxy::spawn_gateway_against_on(ClientConfig::default(), app, cells); - let target = format!("http://x/short-{tag}"); - let r = fetch_when_up(p.fetch_addr(), &auth(&target, "any")); - assert!(is_reset(&r), "{r:?}"); - assert!(h.lock().requests >= 1); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn fetch_short_cl_response_aborts_client() { + let tag = "ww"; + let (app, h) = H3EchoServer::new(EchoMode::ShortCl { cl: 100, sent: 50 }); + let p = LoopbackProxy::spawn_gateway_against(ClientConfig::default(), app, || { + h3_transport(server_role()) + }); + let target = format!("http://x/short-{tag}"); + let r = fetch_when_up(p.fetch_addr(), &auth(&target, "any")); + assert!(is_reset(&r), "{r:?}"); + assert!(h.lock().requests >= 1); + assert_eq!(p.join_both(), (0, 0)); +} /// The declared length of a cut DATA frame, and what the raw peer sends of it. const CUT_DECLARED: u64 = 32 << 20; @@ -574,487 +564,442 @@ const EXCESSIVE_LOAD: CloseReason = CloseReason { }; // spec §3.7 (3), §5.4, Review Focus 5: the response's DATA frame is cut by FIN inside its -// declared length; the local fetch socket is reset in both cells. An xqc_h3 client reads the -// cut as a clean EOF and its body check (or the `H3Closed.unread` path) resets; h3wire closes +// declared length; the local fetch socket is reset. h3wire closes // the connection with `H3_FRAME_ERROR` (adoption spec §5.3 (1)). -receivers!( - fetch_response_cut_inside_frame_aborts, - |b: Backend, tag: &str| { - let cl = CUT_DECLARED.to_string(); - let mut stream = headers_frame(&[(b":status", b"200"), (b"content-length", cl.as_bytes())]); - stream.extend(data_frame(CUT_DECLARED, &[0; CUT_SENT])); - let (app, h) = RawH3Peer::server(RawH3Script { stream, fin: true }); - let p = LoopbackProxy::spawn_gateway_against_on( - ClientConfig::default(), - app, - (b, Backend::Raw), - ); - let target = format!("http://x/cut-{tag}"); - let r = fetch_when_up(p.fetch_addr(), &auth(&target, "any")); - assert!(is_reset(&r), "{:?}", r.as_ref().map(|b| b.len())); - if b == Backend::Wire { - wait_until("the peer's ConnClosed", || !h.lock().closed.is_empty()); - assert_eq!(h.lock().closed, [FRAME_ERROR]); - } - assert_eq!(p.join_both(), (0, 0)); +#[test] +fn fetch_response_cut_inside_frame_aborts() { + let tag = "w"; + let cl = CUT_DECLARED.to_string(); + let mut stream = headers_frame(&[(b":status", b"200"), (b"content-length", cl.as_bytes())]); + stream.extend(data_frame(CUT_DECLARED, &[0; CUT_SENT])); + let (app, h) = RawH3Peer::server(RawH3Script { stream, fin: true }); + let p = LoopbackProxy::spawn_gateway_against(ClientConfig::default(), app, || { + raw_h3_transport(server_role()) + }); + let target = format!("http://x/cut-{tag}"); + let r = fetch_when_up(p.fetch_addr(), &auth(&target, "any")); + assert!(is_reset(&r), "{:?}", r.as_ref().map(|b| b.len())); + { + wait_until("the peer's ConnClosed", || !h.lock().closed.is_empty()); + assert_eq!(h.lock().closed, [FRAME_ERROR]); } -); + assert_eq!(p.join_both(), (0, 0)); +} -/// adoption spec §6.2: a `RawH3Peer::server` hosts the server side of the gateway pair -/// (`Backend::Raw`); its fixed 200 reaches the fetch socket through each client backend. +/// A `RawH3Peer::server` hosts the gateway pair's server side; its fixed 200 reaches +/// the fetch socket through H3Wire. #[test] fn raw_server_smoke() { - for b in [Backend::XqcH3, Backend::Wire] { - let mut stream = headers_frame(&[(b":status", b"200"), (b"content-length", b"2")]); - stream.extend(data_frame(2, b"ok")); - let (app, h) = RawH3Peer::server(RawH3Script { stream, fin: true }); - let p = LoopbackProxy::spawn_gateway_against_on( - ClientConfig::default(), - app, - (b, Backend::Raw), - ); - let r = fetch_when_up(p.fetch_addr(), &auth("http://x/raw", "any")); - let r = parse(&r.unwrap_or_else(|e| panic!("{b:?}: {e}"))); - assert_eq!((r.status, r.body.as_slice()), (200, &b"ok"[..]), "{b:?}"); - assert!( - !h.lock().read.is_empty(), - "{b:?}: the peer read the request" - ); - assert_eq!(p.join_both(), (0, 0), "{b:?}"); - } + let mut stream = headers_frame(&[(b":status", b"200"), (b"content-length", b"2")]); + stream.extend(data_frame(2, b"ok")); + let (app, h) = RawH3Peer::server(RawH3Script { stream, fin: true }); + let p = LoopbackProxy::spawn_gateway_against(ClientConfig::default(), app, || { + raw_h3_transport(server_role()) + }); + let r = fetch_when_up(p.fetch_addr(), &auth("http://x/raw", "any")); + let r = parse(&r.unwrap_or_else(|e| panic!("{e}"))); + assert_eq!((r.status, r.body.as_slice()), (200, &b"ok"[..])); + assert!(!h.lock().read.is_empty(), "the peer read the request"); + assert_eq!(p.join_both(), (0, 0)); } // spec §5.4, §6.4, §10.3: a HEAD response and a 304, each carrying `content-length`, // finish cleanly (the HEAD reply's length is rewritten to 0, §12). -matrix!( - head_and_304_with_cl_finish_cleanly, - |cells: (Backend, Backend), tag: &str| { - let raw = |reply: &[u8]| { - let proto = Proto::RawH1 { - tls: false, - reply: reply.to_vec(), - }; - OriginServer::spawn(OriginServerMode::new(proto, Handler::Echo)) - }; - let head = raw(b"HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\n"); - let nm = raw(b"HTTP/1.1 304 Not Modified\r\ncontent-length: 100\r\n\r\n"); - let p = gateway(cells); - wait_up(&p, tag); - let r = fetch( - &p, - &format!("http://{}/h-{tag}", head.addr), - &[("X-Mq-Method", "HEAD")], - b"", - ); - let r = parse(&r.expect("HEAD finishes cleanly")); - let want = [ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "0"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert_eq!((r.status, r.header("content-length")), (200, Some("0"))); - assert!(r.body.is_empty()); - let r = parse( - &fetch(&p, &format!("http://{}/n-{tag}", nm.addr), &[], b"") - .expect("304 finishes cleanly"), - ); - assert_eq!((r.status, r.header("content-length")), (304, Some("100"))); - let want = [ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "100"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert!(r.body.is_empty()); - for (sid, method, status, addr, path) in [ - (4, "HEAD", 200, head.addr, format!("/h-{tag}")), - (8, "GET", 304, nm.addr, format!("/n-{tag}")), - ] { - let want = format!( - "sid={sid} method={method} status={status} authority=\"{addr}\" path=\"{path}\" req_bytes=0 resp_bytes=0 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"" - ); - assert_req(&req_line(&path), &want); - } - assert_eq!(p.join_both(), (0, 0)); - } -); - -// SP4 spec §5: an origin answering with about 30 KiB of headers (five 6 KiB values, under -// `SECTION_MAX`) is relayed to the local caller; the fetch request itself stays small. -matrix!( - fetch_30k_response_head_relayed, - |cells: (Backend, Backend), tag: &str| { - let value = "v".repeat(6 * 1024); - let mut reply = String::from("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n"); - for i in 0..5 { - reply += &format!("x-big-{i}: {value}\r\n"); - } - reply += "\r\nok"; +#[test] +fn head_and_304_with_cl_finish_cleanly() { + let tag = "ww"; + let raw = |reply: &[u8]| { let proto = Proto::RawH1 { tls: false, - reply: reply.into_bytes(), + reply: reply.to_vec(), }; - let origin = OriginServer::spawn(OriginServerMode::new(proto, Handler::Echo)); - let p = gateway(cells); - wait_up(&p, tag); - let raw = fetch(&p, &format!("http://{}/big-{tag}", origin.addr), &[], b""); - let r = parse(&raw.expect("fetch")); - let names: Vec = (0..5).map(|i| format!("x-big-{i}")).collect(); - let mut want = vec![ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "2"), - ]; - want.extend(names.iter().map(|n| (n.as_str(), value.as_str()))); - want.push(("connection", "close")); - assert_headers(&r.headers, &want); - assert_eq!((r.status, r.body.as_slice()), (200, &b"ok"[..])); - for i in 0..5 { - assert_eq!(r.header(&format!("x-big-{i}")), Some(value.as_str()), "{i}"); - } - let path = format!("/big-{tag}"); + OriginServer::spawn(OriginServerMode::new(proto, Handler::Echo)) + }; + let head = raw(b"HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\n"); + let nm = raw(b"HTTP/1.1 304 Not Modified\r\ncontent-length: 100\r\n\r\n"); + let p = gateway(); + wait_up(&p, tag); + let r = fetch( + &p, + &format!("http://{}/h-{tag}", head.addr), + &[("X-Mq-Method", "HEAD")], + b"", + ); + let r = parse(&r.expect("HEAD finishes cleanly")); + let want = [ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "0"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert_eq!((r.status, r.header("content-length")), (200, Some("0"))); + assert!(r.body.is_empty()); + let r = parse( + &fetch(&p, &format!("http://{}/n-{tag}", nm.addr), &[], b"").expect("304 finishes cleanly"), + ); + assert_eq!((r.status, r.header("content-length")), (304, Some("100"))); + let want = [ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "100"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert!(r.body.is_empty()); + for (sid, method, status, addr, path) in [ + (4, "HEAD", 200, head.addr, format!("/h-{tag}")), + (8, "GET", 304, nm.addr, format!("/n-{tag}")), + ] { let want = format!( - "sid=4 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", - origin.addr + "sid={sid} method={method} status={status} authority=\"{addr}\" path=\"{path}\" req_bytes=0 resp_bytes=0 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"" ); assert_req(&req_line(&path), &want); - assert_eq!(p.join_both(), (0, 0)); } -); + assert_eq!(p.join_both(), (0, 0)); +} -// spec §7.4, §10.3: `/a/../b` reaches the origin as `/b` (the query untouched). -matrix!( - dot_segments_normalised_at_origin, - |cells: (Backend, Backend), tag: &str| { - let o = Capture::spawn(); - let p = gateway(cells); - wait_up(&p, tag); - let url = o.url(&format!("/a/../b-{tag}?q=/../x")); - let r = parse(&fetch(&p, &url, &[], b"").expect("fetch")); - let want = [ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "2"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert_eq!((r.status, r.body.as_slice()), (200, &b"ok"[..])); - let head = o.one().head; - let want = format!("get /b-{tag}?q=/../x http/1.1\r\n"); - assert!(head.starts_with(&want), "{head}"); - // The line keeps the path as sent, cut at `?`. - let path = format!("/a/../b-{tag}"); - let want = format!( - "sid=4 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", - o.authority() - ); - assert_req(&req_line(&path), &want); - assert_eq!(p.join_both(), (0, 0)); +// SP4 spec §5: an origin answering with about 30 KiB of headers (five 6 KiB values, under +// `SECTION_MAX`) is relayed to the local caller; the fetch request itself stays small. +#[test] +fn fetch_30k_response_head_relayed() { + let tag = "ww"; + let value = "v".repeat(6 * 1024); + let mut reply = String::from("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n"); + for i in 0..5 { + reply += &format!("x-big-{i}: {value}\r\n"); } -); + reply += "\r\nok"; + let proto = Proto::RawH1 { + tls: false, + reply: reply.into_bytes(), + }; + let origin = OriginServer::spawn(OriginServerMode::new(proto, Handler::Echo)); + let p = gateway(); + wait_up(&p, tag); + let raw = fetch(&p, &format!("http://{}/big-{tag}", origin.addr), &[], b""); + let r = parse(&raw.expect("fetch")); + let names: Vec = (0..5).map(|i| format!("x-big-{i}")).collect(); + let mut want = vec![ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "2"), + ]; + want.extend(names.iter().map(|n| (n.as_str(), value.as_str()))); + want.push(("connection", "close")); + assert_headers(&r.headers, &want); + assert_eq!((r.status, r.body.as_slice()), (200, &b"ok"[..])); + for i in 0..5 { + assert_eq!(r.header(&format!("x-big-{i}")), Some(value.as_str()), "{i}"); + } + let path = format!("/big-{tag}"); + let want = format!( + "sid=4 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", + origin.addr + ); + assert_req(&req_line(&path), &want); + assert_eq!(p.join_both(), (0, 0)); +} + +// spec §7.4, §10.3: `/a/../b` reaches the origin as `/b` (the query untouched). +#[test] +fn dot_segments_normalised_at_origin() { + let tag = "ww"; + let o = Capture::spawn(); + let p = gateway(); + wait_up(&p, tag); + let url = o.url(&format!("/a/../b-{tag}?q=/../x")); + let r = parse(&fetch(&p, &url, &[], b"").expect("fetch")); + let want = [ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "2"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert_eq!((r.status, r.body.as_slice()), (200, &b"ok"[..])); + let head = o.one().head; + let want = format!("get /b-{tag}?q=/../x http/1.1\r\n"); + assert!(head.starts_with(&want), "{head}"); + // The line keeps the path as sent, cut at `?`. + let path = format!("/a/../b-{tag}"); + let want = format!( + "sid=4 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", + o.authority() + ); + assert_req(&req_line(&path), &want); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §5: an empty forwarded `x-test:` is sent, and an empty `accept:` // is sent and suppresses the default `accept: */*`. -matrix!( - empty_header_sent_empty_accept_suppresses_default, - |cells: (Backend, Backend), tag: &str| { - let o = Capture::spawn(); - let p = gateway(cells); - wait_up(&p, tag); - let extra = [("X-Test", ""), ("Accept", ""), ("X-Kept", "yes")]; - let r = parse(&fetch(&p, &o.url(&format!("/empty-{tag}")), &extra, b"").expect("fetch")); - let want = [ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "2"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert_eq!(r.status, 200); - let head = o.one().head; - assert!(head.contains("\r\nx-kept: yes\r\n"), "{head}"); - assert!(head.contains("\r\nx-test:"), "{head}"); - assert!(head.contains("\r\naccept:"), "{head}"); - assert!(!head.contains("*/*"), "{head}"); - let path = format!("/empty-{tag}"); - let want = format!( - "sid=4 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", - o.authority() - ); - assert_req(&req_line(&path), &want); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn empty_header_sent_empty_accept_suppresses_default() { + let tag = "ww"; + let o = Capture::spawn(); + let p = gateway(); + wait_up(&p, tag); + let extra = [("X-Test", ""), ("Accept", ""), ("X-Kept", "yes")]; + let r = parse(&fetch(&p, &o.url(&format!("/empty-{tag}")), &extra, b"").expect("fetch")); + let want = [ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "2"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert_eq!(r.status, 200); + let head = o.one().head; + assert!(head.contains("\r\nx-kept: yes\r\n"), "{head}"); + assert!(head.contains("\r\nx-test:"), "{head}"); + assert!(head.contains("\r\naccept:"), "{head}"); + assert!(!head.contains("*/*"), "{head}"); + let path = format!("/empty-{tag}"); + let want = format!( + "sid=4 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", + o.authority() + ); + assert_req(&req_line(&path), &want); + assert_eq!(p.join_both(), (0, 0)); +} // spec §7.2, §7.7, §10.3 (e2e case 13): an h1-forced request reuses the idle h1 conn a // default request left in the pool — against an origin that serves one conn at a time, a // second dial would hang until `curl:28`. -matrix!( - h1_forced_reuses_idle_default_conn, - |cells: (Backend, Backend), tag: &str| { - let mode = OriginServerMode { - single_conn: true, - ..OriginServerMode::new(Proto::H1Tls, Handler::FileBytes(2)) - }; - let o = OriginServer::spawn(mode); - let mut cfg = server_cfg(); - if let Some(g) = cfg.gateway.as_mut() { - g.origin_connect_timeout = Duration::from_secs(2); - } - let p = gateway_with(cfg, cells); - wait_up(&p, tag); - let url = format!("https://127.0.0.1:{}/c13-{tag}", o.addr.port()); - let r = parse(&fetch(&p, &url, &[], b"").expect("default request")); - let want = [ - ("x-mq-origin-protocol", "http/1.1"), - ("content-length", "2"), - ("date", "T"), - ("connection", "close"), - ]; - assert_headers(&r.headers, &want); - assert_eq!((r.status, r.body.len()), (200, 2)); - let r = fetch(&p, &url, &[("X-Mq-Origin-Protocol", "h1")], b""); - let r = parse(&r.expect("h1-forced request")); - assert_headers(&r.headers, &want); - assert_eq!((r.status, r.body.len()), (200, 2)); - assert_eq!(o.accepted(), 1); - let path = format!("/c13-{tag}"); - let a = format!("127.0.0.1:{}", o.addr.port()); - // The two lines may close in either order: each is found by its stream id. - for (sid, reuse) in [(4, 0), (8, 1)] { - let marker = format!("path=\"{path}\""); - let line = wait_log(&["mq.req", &format!("sid={sid} "), &marker]); - let want = format!( - "sid={sid} method=GET status=200 authority=\"{a}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=ok content_encoding=none cache=bypass origin_reuse={reuse} origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"" - ); - assert_req(&line, &want); - } - assert_eq!(p.join_both(), (0, 0)); +#[test] +fn h1_forced_reuses_idle_default_conn() { + let tag = "ww"; + let mode = OriginServerMode { + single_conn: true, + ..OriginServerMode::new(Proto::H1Tls, Handler::FileBytes(2)) + }; + let o = OriginServer::spawn(mode); + let mut cfg = server_cfg(); + if let Some(g) = cfg.gateway.as_mut() { + g.origin_connect_timeout = Duration::from_secs(2); } -); + let p = gateway_with(cfg); + wait_up(&p, tag); + let url = format!("https://127.0.0.1:{}/c13-{tag}", o.addr.port()); + let r = parse(&fetch(&p, &url, &[], b"").expect("default request")); + let want = [ + ("x-mq-origin-protocol", "http/1.1"), + ("content-length", "2"), + ("date", "T"), + ("connection", "close"), + ]; + assert_headers(&r.headers, &want); + assert_eq!((r.status, r.body.len()), (200, 2)); + let r = fetch(&p, &url, &[("X-Mq-Origin-Protocol", "h1")], b""); + let r = parse(&r.expect("h1-forced request")); + assert_headers(&r.headers, &want); + assert_eq!((r.status, r.body.len()), (200, 2)); + assert_eq!(o.accepted(), 1); + let path = format!("/c13-{tag}"); + let a = format!("127.0.0.1:{}", o.addr.port()); + // The two lines may close in either order: each is found by its stream id. + for (sid, reuse) in [(4, 0), (8, 1)] { + let marker = format!("path=\"{path}\""); + let line = wait_log(&["mq.req", &format!("sid={sid} "), &marker]); + let want = format!( + "sid={sid} method=GET status=200 authority=\"{a}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=ok content_encoding=none cache=bypass origin_reuse={reuse} origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"" + ); + assert_req(&line, &want); + } + assert_eq!(p.join_both(), (0, 0)); +} // spec §6.4, §10.3: an h2 origin declares `content-length: 100`, sends 50 bytes and then // RST_STREAM(NO_ERROR): the server's body check resets the H3 response (pinned by its // `mq.req`). The Rust client's local reply is an abort, or 502 `upstream-reset` when its // xquic processes HEADERS and RESET_STREAM in one pass (the reset discards the unread // head; `ClTooShort` leaves only 10 ms between them, too little on a slow CI). -matrix!( - h2_cl_100_then_rst_resets_h3_end_to_end, - |cells: (Backend, Backend), tag: &str| { - let h = Handler::ClTooShort { cl: 100, send: 50 }; - let o = OriginServer::spawn(OriginServerMode::new(Proto::H2Tls, h)); - let p = gateway(cells); - wait_up(&p, tag); - let url = format!("https://127.0.0.1:{}/h2-short-{tag}", o.addr.port()); - let r = fetch(&p, &url, &[], b""); - let upstream_reset = b"HTTP/1.1 502 \r\nX-Mq-Error: upstream-reset\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - assert!( - is_reset(&r) || matches!(&r, Ok(b) if b == upstream_reset), - "{:?}", - r.as_ref().map(|b| String::from_utf8_lossy(b).into_owned()) - ); - // The reset is the server's (its check), not only the client's own body check; it - // leaves no FIN stamps. `origin_tls=connect_fail` and `origin_connect_ms=-1` pin an - // existing gateway misreport, outside this plan: the origin connect succeeded. A - // later gateway fix that changes these two fields is not a regression. - let path = format!("/h2-short-{tag}"); - let want = format!( - "sid=4 method=GET status=200 authority=\"127.0.0.1:{}\" path=\"{path}\" req_bytes=0 resp_bytes=50 ttfb_ms=T duration_ms=-1 origin_protocol=h2 origin_tls=connect_fail content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=-1 reset=\"local reset\"", - o.addr.port() - ); - assert_req(&req_line(&path), &want); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn h2_cl_100_then_rst_resets_h3_end_to_end() { + let tag = "ww"; + let h = Handler::ClTooShort { cl: 100, send: 50 }; + let o = OriginServer::spawn(OriginServerMode::new(Proto::H2Tls, h)); + let p = gateway(); + wait_up(&p, tag); + let url = format!("https://127.0.0.1:{}/h2-short-{tag}", o.addr.port()); + let r = fetch(&p, &url, &[], b""); + let upstream_reset = b"HTTP/1.1 502 \r\nX-Mq-Error: upstream-reset\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + assert!( + is_reset(&r) || matches!(&r, Ok(b) if b == upstream_reset), + "{:?}", + r.as_ref().map(|b| String::from_utf8_lossy(b).into_owned()) + ); + // The reset is the server's (its check), not only the client's own body check; it + // leaves no FIN stamps. `origin_tls=connect_fail` and `origin_connect_ms=-1` pin an + // existing gateway misreport, outside this plan: the origin connect succeeded. A + // later gateway fix that changes these two fields is not a regression. + let path = format!("/h2-short-{tag}"); + let want = format!( + "sid=4 method=GET status=200 authority=\"127.0.0.1:{}\" path=\"{path}\" req_bytes=0 resp_bytes=50 ttfb_ms=T duration_ms=-1 origin_protocol=h2 origin_tls=connect_fail content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=-1 reset=\"local reset\"", + o.addr.port() + ); + assert_req(&req_line(&path), &want); + assert_eq!(p.join_both(), (0, 0)); +} // spec §5.2, §6.2, §10.3: CONNECT is refused by the fetch listener (400 `bad-method`) and // by the server intake (400 `bad-request`). The direct-H3 CONNECT is the valid authority -// form (RFC 9114 §4.4), so it reaches the gateway on both server backends (adoption spec -// §6.2; the malformed form is a raw-peer case). -matrix!( - connect_rejected_both_intakes, - |cells: (Backend, Backend), tag: &str| { - let p = gateway(cells); - let target = format!("http://127.0.0.1:1/connect-{tag}"); - let r = fetch(&p, &target, &[("X-Mq-Method", "CONNECT")], b""); - let want = b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\nX-Mq-Error: bad-method\r\n\r\n"; - assert_eq!( - String::from_utf8_lossy(&r.expect("reply")), - String::from_utf8_lossy(want) - ); - let auth = bearer(); - let headers = [ - (":method", "CONNECT"), - (":authority", "127.0.0.1:1"), - ("x-mq-auth", &auth), +// form (RFC 9114 §4.4), so it reaches the gateway over H3Wire (the malformed form is a raw-peer case). +#[test] +fn connect_rejected_both_intakes() { + let tag = "ww"; + let p = gateway(); + let target = format!("http://127.0.0.1:1/connect-{tag}"); + let r = fetch(&p, &target, &[("X-Mq-Method", "CONNECT")], b""); + let want = b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\nX-Mq-Error: bad-method\r\n\r\n"; + assert_eq!( + String::from_utf8_lossy(&r.expect("reply")), + String::from_utf8_lossy(want) + ); + let auth = bearer(); + let headers = [ + (":method", "CONNECT"), + (":authority", "127.0.0.1:1"), + ("x-mq-auth", &auth), + ]; + // Its line has `path="-"`: a body of four bytes (drained, `req_bytes`) marks it. + let n = 4; + let s = H3Script { + headers: headers.map(|(n, v)| (n.into(), v.into())).to_vec(), + body: vec![0; n], + }; + let c = h3_client(p.server.udp_addr, s); + wait_h3(&c.handle, |r| r.fin); + { + let r = c.handle.lock(); + let want = [ + (":status", "400"), + ("x-mq-error", "bad-request"), + ("content-length", "0"), ]; - // Its line has `path="-"`: a body of `cell + 1` bytes (drained, `req_bytes`) marks it. - let n = cell_index(cells) + 1; - let s = H3Script { - headers: headers.map(|(n, v)| (n.into(), v.into())).to_vec(), - body: vec![0; n], - ..H3Script::default() - }; - let c = h3_client(p.server.udp_addr, s, cells.0); - wait_h3(&c.handle, |r| r.fin); - { - let r = c.handle.lock(); - let want = [ - (":status", "400"), - ("x-mq-error", "bad-request"), - ("content-length", "0"), - ]; - assert_headers(&h3_headers(&r), &want); - } - let line = wait_log(&["mq.req", "status=400", &format!("req_bytes={n} ")]); - let want = format!( - "sid=0 method=- status=400 authority=\"-\" path=\"-\" req_bytes={n} resp_bytes=0 ttfb_ms=T duration_ms=T origin_protocol=none origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=T reset=\"\"" - ); - assert_req(&line, &want); - stop(c); - assert_eq!(p.join_both(), (0, 0)); + assert_headers(&h3_headers(&r), &want); } -); + let line = wait_log(&["mq.req", "status=400", &format!("req_bytes={n} ")]); + let want = format!( + "sid=0 method=- status=400 authority=\"-\" path=\"-\" req_bytes={n} resp_bytes=0 ttfb_ms=T duration_ms=T origin_protocol=none origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=T reset=\"\"" + ); + assert_req(&line, &want); + stop(c); + assert_eq!(p.join_both(), (0, 0)); +} // ---- direct H3 against the gateway server ---- // spec §3.7 (1), §6.3, Review Focus 3: a 4 MiB body sent after a 403 is drained, so the // server's `recv_body_size` (`req_bytes`) reports the whole body. -// A 403 line has `path="-"`, so the cell's marker is its body length. -matrix!( - drain_after_403_counts_whole_body, - |cells: (Backend, Backend), tag: &str| { - let p = gateway(cells); - let body_len = 4 * MIB + cell_index(cells); - let extra = [("x-mq-auth", "Bearer wrong")]; - let path = format!("/drain-{tag}"); - let s = script("POST", "127.0.0.1:1", &path, &extra, vec![7; body_len]); - let c = h3_client(p.server.udp_addr, s, cells.0); - wait_h3(&c.handle, |r| !r.closed.is_empty()); - { - let r = c.handle.lock(); - let want = [ - (":status", "403"), - ("x-mq-error", "auth-failed"), - ("content-length", "0"), - ]; - assert_headers(&h3_headers(&r), &want); - assert_eq!(r.closed[0].0.stats.stream_err, 0, "{:?}", r.closed); - assert_eq!(r.closed[0].0.stats.send_body, body_len as u64); - } - let line = wait_log(&["mq.req", "status=403", &format!("req_bytes={body_len} ")]); - let want = format!( - "sid=0 method=- status=403 authority=\"-\" path=\"-\" req_bytes={body_len} resp_bytes=0 ttfb_ms=T duration_ms=T origin_protocol=none origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=T reset=\"\"" - ); - assert_req(&line, &want); - stop(c); - assert_eq!(p.join_both(), (0, 0)); +// A 403 line has `path="-"`, so the test's marker is its body length. +#[test] +fn drain_after_403_counts_whole_body() { + let tag = "ww"; + let p = gateway(); + let body_len = 4 * MIB + 3; + let extra = [("x-mq-auth", "Bearer wrong")]; + let path = format!("/drain-{tag}"); + let s = script("POST", "127.0.0.1:1", &path, &extra, vec![7; body_len]); + let c = h3_client(p.server.udp_addr, s); + wait_h3(&c.handle, |r| !r.closed.is_empty()); + { + let r = c.handle.lock(); + let want = [ + (":status", "403"), + ("x-mq-error", "auth-failed"), + ("content-length", "0"), + ]; + assert_headers(&h3_headers(&r), &want); + assert_eq!(r.closed[0].0.stats.stream_err, 0, "{:?}", r.closed); + assert_eq!(r.closed[0].0.stats.send_body, body_len as u64); } -); + let line = wait_log(&["mq.req", "status=403", &format!("req_bytes={body_len} ")]); + let want = format!( + "sid=0 method=- status=403 authority=\"-\" path=\"-\" req_bytes={body_len} resp_bytes=0 ttfb_ms=T duration_ms=T origin_protocol=none origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=T reset=\"\"" + ); + assert_req(&line, &want); + stop(c); + assert_eq!(p.join_both(), (0, 0)); +} // spec §6.2 step 8, §10.3: `content-length: 10` with FIN on HEADERS reaches the origin // bodiless (no chunked framing, no positive length). adoption spec §5.3 (6): a `Wire` server // finds the mismatch in the read that delivers the HEADERS, so the gateway's intake read // fails and the origin sees nothing. Its line has `path="-"` and no field unique to the -// cell, so the cell asserts no `mq.req` (as the brief rules); the client sees the reset. -matrix!( - direct_h3_cl_with_fin_on_headers_is_bodiless, - |cells: (Backend, Backend), tag: &str| { - let o = Capture::spawn(); - let p = gateway(cells); - let auth = bearer(); - let extra = [("x-mq-auth", auth.as_str()), ("content-length", "10")]; - let path = format!("/fin-{tag}"); - let s = script("POST", &o.authority(), &path, &extra, Vec::new()); - let c = h3_client(p.server.udp_addr, s, cells.0); - if cells.1 == Backend::Wire { - wait_h3(&c.handle, |r| !r.closed.is_empty()); - { - let r = c.handle.lock(); - assert_eq!(r.closed[0].0.stats.stream_err, 0x10e, "{r:?}"); - assert!(r.headers.is_empty() && !r.fin, "{r:?}"); - } - stop(c); - assert_eq!(p.join_both(), (0, 0)); - assert!(o.settled().is_empty(), "the origin saw a request"); - return; +// test, so it asserts no `mq.req` (as the brief rules); the client sees the reset. +#[test] +fn direct_h3_cl_with_fin_on_headers_is_bodiless() { + let tag = "ww"; + let o = Capture::spawn(); + let p = gateway(); + let auth = bearer(); + let extra = [("x-mq-auth", auth.as_str()), ("content-length", "10")]; + let path = format!("/fin-{tag}"); + let s = script("POST", &o.authority(), &path, &extra, Vec::new()); + let c = h3_client(p.server.udp_addr, s); + { + wait_h3(&c.handle, |r| !r.closed.is_empty()); + { + let r = c.handle.lock(); + assert_eq!(r.closed[0].0.stats.stream_err, 0x10e, "{r:?}"); + assert!(r.headers.is_empty() && !r.fin, "{r:?}"); } - wait_h3(&c.handle, |r| r.fin); - // The control of `assert_not_seen`: a request the gateway took is counted. - assert_eq!(p.server_h3_requests.load(Ordering::SeqCst), 1); - assert_headers(&h3_headers(&c.handle.lock()), &H3_OK); - let seen = o.one(); - assert!(seen.complete && seen.body.is_empty(), "{seen:?}"); - assert!(!seen.head.contains("transfer-encoding"), "{}", seen.head); - let cl = seen - .head - .lines() - .find_map(|l| l.strip_prefix("content-length:")); - assert!(cl.is_none_or(|v| v.trim() == "0"), "{}", seen.head); - let want = format!( - "sid=0 method=POST status=200 authority=\"{}\" path=\"{path}\" req_bytes=0 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", - o.authority() - ); - assert_req(&req_line(&path), &want); stop(c); assert_eq!(p.join_both(), (0, 0)); + assert!(o.settled().is_empty(), "the origin saw a request"); } -); +} // spec §7.4, §10.3: a GET with an unknown-length DATA body reaches an h1 origin chunked and // complete. -matrix!( - direct_h3_get_with_body_is_chunked_on_h1, - |cells: (Backend, Backend), tag: &str| { - let o = Capture::spawn(); - let p = gateway(cells); - let auth = bearer(); - let extra = [("x-mq-auth", auth.as_str())]; - let path = format!("/chunked-{tag}"); - let s = script("GET", &o.authority(), &path, &extra, b"hello body".to_vec()); - let c = h3_client(p.server.udp_addr, s, cells.0); - wait_h3(&c.handle, |r| r.fin); - assert_headers(&h3_headers(&c.handle.lock()), &H3_OK); - let seen = o.one(); - assert!( - seen.head.contains("\r\ntransfer-encoding: chunked\r\n"), - "{}", - seen.head - ); - assert_eq!(dechunk(&seen.body).as_deref(), Some(&b"hello body"[..])); - let want = format!( - "sid=0 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=10 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", - o.authority() - ); - assert_req(&req_line(&path), &want); - stop(c); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn direct_h3_get_with_body_is_chunked_on_h1() { + let tag = "ww"; + let o = Capture::spawn(); + let p = gateway(); + let auth = bearer(); + let extra = [("x-mq-auth", auth.as_str())]; + let path = format!("/chunked-{tag}"); + let s = script("GET", &o.authority(), &path, &extra, b"hello body".to_vec()); + let c = h3_client(p.server.udp_addr, s); + wait_h3(&c.handle, |r| r.fin); + assert_headers(&h3_headers(&c.handle.lock()), &H3_OK); + let seen = o.one(); + assert!( + seen.head.contains("\r\ntransfer-encoding: chunked\r\n"), + "{}", + seen.head + ); + assert_eq!(dechunk(&seen.body).as_deref(), Some(&b"hello body"[..])); + let want = format!( + "sid=0 method=GET status=200 authority=\"{}\" path=\"{path}\" req_bytes=10 resp_bytes=2 ttfb_ms=T duration_ms=T origin_protocol=h1 origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=T mp_state=0 completion_ms=T reset=\"\"", + o.authority() + ); + assert_req(&req_line(&path), &want); + stop(c); + assert_eq!(p.join_both(), (0, 0)); +} /// A direct-H3 upload the server must reset (§6.3): the client sees the reset, the server /// logs the request, and the origin never received a complete body. On a `Wire` server the /// reset is h3wire's (`H3_MESSAGE_ERROR`, adoption spec §5.3 (6)) and comes before any body -/// byte reaches the gateway (`req_bytes=0`, where xqc_h3 delivers the 50 bytes); the gateway +/// byte reaches the gateway (`req_bytes=0`); the gateway /// still logs it as `local reset`. -fn upload_reset(cells: (Backend, Backend), path: &str, cl: &str, body: Vec) { +fn upload_reset(path: &str, cl: &str, body: Vec) { let o = Capture::spawn(); - let p = gateway(cells); + let p = gateway(); let auth = bearer(); let extra = [("x-mq-auth", auth.as_str()), ("content-length", cl)]; let s = script("POST", &o.authority(), path, &extra, body); - let c = h3_client(p.server.udp_addr, s, cells.0); + let c = h3_client(p.server.udp_addr, s); wait_h3(&c.handle, |r| !r.closed.is_empty()); - let wire = cells.1 == Backend::Wire; { let r = c.handle.lock(); let code = r.closed[0].0.stats.stream_err; - assert!(if wire { code == 0x10e } else { code != 0 }, "{r:?}"); + assert_eq!(code, 0x10e, "{r:?}"); assert!(!r.fin, "{r:?}"); assert_headers(&h3_headers(&r), &[]); } let want = format!( "sid=0 method=POST status=0 authority=\"{}\" path=\"{path}\" req_bytes={} resp_bytes=0 ttfb_ms=-1 duration_ms=-1 origin_protocol=none origin_tls=na content_encoding=none cache=bypass origin_reuse=0 origin_connect_ms=-1 mp_state=0 completion_ms=-1 reset=\"local reset\"", o.authority(), - if wire { 0 } else { 50 }, + 0, ); assert_req(&req_line(path), &want); let seen = o.settled(); @@ -1064,20 +1009,18 @@ fn upload_reset(cells: (Backend, Backend), path: &str, cl: &str, body: Vec) } // spec §6.3, §10.3: 50 bytes + FIN (a complete frame) under `content-length: 100`. -matrix!( - direct_h3_short_request_body_resets, - |cells: (Backend, Backend), tag: &str| { - upload_reset(cells, &format!("/short-req-{tag}"), "100", vec![1; 50]); - } -); +#[test] +fn direct_h3_short_request_body_resets() { + let tag = "ww"; + upload_reset(&format!("/short-req-{tag}"), "100", vec![1; 50]); +} // spec §6.3, §10.3: 50 bytes under `content-length: 10`. -matrix!( - direct_h3_excess_request_body_resets, - |cells: (Backend, Backend), tag: &str| { - upload_reset(cells, &format!("/excess-req-{tag}"), "10", vec![1; 50]); - } -); +#[test] +fn direct_h3_excess_request_body_resets() { + let tag = "ww"; + upload_reset(&format!("/excess-req-{tag}"), "10", vec![1; 50]); +} // ---- malformed input against each receiver (adoption spec §6.2) ---- @@ -1101,20 +1044,6 @@ fn assert_message_error(p: &LoopbackProxy, c: &DriverThread) { assert_not_seen(p); } -/// No `mq.req` line carries `marker` for 500 ms (the xqc_h3 receiver: it can log the -/// request as `path="-"` before decoding, so only the submitted path is searched for). -fn assert_no_req(marker: &str) { - let end = Instant::now() + Duration::from_millis(500); - while Instant::now() < end { - let lines = log_tap::lines(); - let hit = lines - .iter() - .find(|l| l.contains("mq.req") && l.contains(marker)); - assert!(hit.is_none(), "{hit:?}"); - thread::sleep(Duration::from_millis(5)); - } -} - /// The server never handed the gateway a request (`H3Request`) for 500 ms. Counted at the /// server app of this test's own pair, so neither a `path="-"` log line (the gateway's name /// for a request it has not accepted) nor another test's lines can hide a leak. @@ -1127,66 +1056,58 @@ fn assert_not_seen(p: &LoopbackProxy) { } } -/// The raw response's `name` field. -fn raw_field(c: &DriverThread, name: &str) -> Option { - let v = field(&c.handle.lock().read, name.as_bytes()); - v.map(|v| String::from_utf8_lossy(&v).into_owned()) -} - // spec §3.7 (3), §6.3, Review Focus 5: the request's DATA frame is cut by FIN inside its -// declared length. An xqc_h3 server reads the cut as a clean EOF and its upload check resets -// the request. h3wire closes the connection with `H3_FRAME_ERROR` (adoption spec §5.3 (1)); +// declared length. h3wire closes the connection with `H3_FRAME_ERROR` (adoption spec §5.3 (1)); // the valid HEADERS reached the gateway first, so the connection-close fan-out (§4.3) logs // the request once, reset. -receivers!( - direct_h3_request_body_cut_inside_frame_resets, - |b: Backend, tag: &str| { - let o = Capture::spawn(); - let p = gateway((Backend::XqcH3, b)); - let (auth, cl, path) = ( - bearer(), - CUT_DECLARED.to_string(), - format!("/cut-req-{tag}"), - ); - let mut stream = raw_headers(&[ - (":method", "POST"), - (":scheme", "http"), - (":authority", &o.authority()), - (":path", &path), - ("x-mq-auth", &auth), - ("content-length", &cl), - ]); - stream.extend(data_frame(CUT_DECLARED, &[2; CUT_SENT])); - let c = raw_client(p.server.udp_addr, stream); - let marker = format!("path=\"{path}\""); - let line = wait_log(&["mq.req", &marker]); - if b == Backend::Wire { - wait_until("the peer's ConnClosed", || { - !c.handle.lock().closed.is_empty() - }); - assert_eq!(c.handle.lock().closed, [FRAME_ERROR]); - assert!(!line.contains("reset=\"\""), "{line}"); - let n = log_tap::lines() - .iter() - .filter(|l| l.contains("mq.req") && l.contains(&marker)) - .count(); - assert_eq!(n, 1); - } else { - assert!(line.contains("reset=\"local reset\""), "{line}"); - } - let seen = o.settled(); - assert!(seen.iter().all(|s| !s.complete), "{seen:?}"); - stop(c); - assert_eq!(p.join_both(), (0, 0)); +#[test] +fn direct_h3_request_body_cut_inside_frame_resets() { + let tag = "w"; + let o = Capture::spawn(); + let p = gateway(); + let (auth, cl, path) = ( + bearer(), + CUT_DECLARED.to_string(), + format!("/cut-req-{tag}"), + ); + let mut stream = raw_headers(&[ + (":method", "POST"), + (":scheme", "http"), + (":authority", &o.authority()), + (":path", &path), + ("x-mq-auth", &auth), + ("content-length", &cl), + ]); + stream.extend(data_frame(CUT_DECLARED, &[2; CUT_SENT])); + let c = raw_client(p.server.udp_addr, stream); + let marker = format!("path=\"{path}\""); + let line = wait_log(&["mq.req", &marker]); + { + wait_until("the peer's ConnClosed", || { + !c.handle.lock().closed.is_empty() + }); + assert_eq!(c.handle.lock().closed, [FRAME_ERROR]); + assert!(!line.contains("reset=\"\""), "{line}"); + let n = log_tap::lines() + .iter() + .filter(|l| l.contains("mq.req") && l.contains(&marker)) + .count(); + assert_eq!(n, 1); } -); + let seen = o.settled(); + assert!(seen.iter().all(|s| !s.complete), "{seen:?}"); + stop(c); + assert_eq!(p.join_both(), (0, 0)); +} -// spec §6.2, §12, §10.3: an embedded NUL in `x-mq-auth`, `:path` and `x-mq-class`. An -// xqc_h3 server passes them to the gateway: 403 / 400 / `?` in the log. RFC 9114 §4.2 makes +// spec §6.2, §12, §10.3: an embedded NUL in `x-mq-auth`, `:path` and `x-mq-class`. +// RFC 9114 §4.2 makes // such a request malformed, and h3wire resets it (adoption spec §5.3 (6)). -receivers!(nul_in_auth_path_class_direct_h3, |b: Backend, tag: &str| { +#[test] +fn nul_in_auth_path_class_direct_h3() { + let tag = "w"; let o = Capture::spawn(); - let p = gateway((Backend::XqcH3, b)); + let p = gateway(); let auth = bearer(); let nul_auth = format!("{auth}\0junk"); let class = format!("nul\0class-marker-{tag}"); @@ -1206,48 +1127,29 @@ receivers!(nul_in_auth_path_class_direct_h3, |b: Backend, tag: &str| { format!("/ok\0junk-{tag}"), format!("/nul-class-{tag}"), ); - let cases = [ - ( - get(&p_auth, &[("x-mq-auth", &nul_auth)]), - &p_auth, - ("403", Some("auth-failed")), - ), - ( - get(&p_path, &[("x-mq-auth", &auth)]), - &p_path, - ("400", Some("bad-target")), - ), - ( - get(&p_class, &[("x-mq-auth", &auth), ("x-mq-class", &class)]), - &p_class, - ("200", None), - ), - ]; - for (stream, _, (status, xmq)) in cases { + for stream in [ + get(&p_auth, &[("x-mq-auth", &nul_auth)]), + get(&p_path, &[("x-mq-auth", &auth)]), + get(&p_class, &[("x-mq-auth", &auth), ("x-mq-class", &class)]), + ] { let c = raw_client(p.server.udp_addr, stream); - if b == Backend::Wire { - assert_message_error(&p, &c); - } else { - wait_until("the response", || c.handle.lock().fin); - assert_eq!(raw_field(&c, ":status").as_deref(), Some(status)); - assert_eq!(raw_field(&c, "x-mq-error").as_deref(), xmq); - } + assert_message_error(&p, &c); stop(c); } let class_line = format!("x-mq-class='nul?class-marker-{tag}'"); - if b == Backend::Wire { + { assert!(!log_tap::lines().iter().any(|l| l.contains(&class_line))); - } else { - wait_log(&[&class_line]); } assert_eq!(p.join_both(), (0, 0)); -}); +} -// spec §5.2, §6.2: CONNECT with `:scheme` and `:path` (RFC 9114 §4.4 forbids both). An -// xqc_h3 server passes it to the gateway, which answers 400 `bad-request`; h3wire resets it +// spec §5.2, §6.2: CONNECT with `:scheme` and `:path` (RFC 9114 §4.4 forbids both). +// h3wire resets it // as malformed (adoption spec §5.3 (6)). -receivers!(connect_malformed_direct_h3, |b: Backend, tag: &str| { - let p = gateway((Backend::XqcH3, b)); +#[test] +fn connect_malformed_direct_h3() { + let tag = "w"; + let p = gateway(); let (auth, path) = (bearer(), format!("/connect-{tag}")); let stream = raw_headers(&[ (":method", "CONNECT"), @@ -1257,87 +1159,62 @@ receivers!(connect_malformed_direct_h3, |b: Backend, tag: &str| { ("x-mq-auth", &auth), ]); let c = raw_client(p.server.udp_addr, stream); - if b == Backend::Wire { + { assert_message_error(&p, &c); - } else { - wait_until("the response", || c.handle.lock().fin); - assert_eq!(raw_field(&c, ":status").as_deref(), Some("400")); - assert_eq!(raw_field(&c, "x-mq-error").as_deref(), Some("bad-request")); } stop(c); assert_eq!(p.join_both(), (0, 0)); -}); +} // adoption spec §5.3 (3): a request HEADERS frame over 64 KiB encoded (`~` has no Huffman // gain, so ten 7000-byte values stay raw). h3wire closes the connection with -// `H3_EXCESSIVE_LOAD` at the frame header. xqc_h3 limits the decoded size instead and resets -// the stream with `H3_MESSAGE_ERROR`, keeping the connection. Neither reaches the gateway. -receivers!( - direct_h3_oversize_headers_rejected, - |b: Backend, tag: &str| { - let p = gateway((Backend::XqcH3, b)); - let (auth, path, pad) = (bearer(), format!("/big-req-{tag}"), "~".repeat(7000)); - let names: Vec = (0..10).map(|i| format!("x-pad-{i}")).collect(); - let mut fs = vec![ - (":method", "GET"), - (":scheme", "http"), - (":authority", "127.0.0.1:1"), - (":path", path.as_str()), - ("x-mq-auth", auth.as_str()), - ]; - fs.extend(names.iter().map(|n| (n.as_str(), pad.as_str()))); - let stream = raw_headers(&fs); - assert!(stream.len() > 64 * 1024, "{}", stream.len()); - let c = raw_client(p.server.udp_addr, stream); - if b == Backend::Wire { - wait_until("the peer's ConnClosed", || { - !c.handle.lock().closed.is_empty() - }); - assert_eq!(c.handle.lock().closed, [EXCESSIVE_LOAD]); - { - let g = c.handle.lock(); - assert!(g.resets.is_empty(), "{g:?}"); - } - } else { - wait_until("the peer's reset", || !c.handle.lock().resets.is_empty()); - { - let g = c.handle.lock(); - assert_eq!(g.resets[0].1, 0x10e, "{g:?}"); - } - { - let g = c.handle.lock(); - assert!(g.closed.is_empty(), "{g:?}"); - } - } - if b == Backend::Wire { - assert_not_seen(&p); - } else { - // xqc_h3 hands the gateway the stream before it decodes (and rejects) the block. - assert_no_req(&format!("path=\"{path}\"")); +// `H3_EXCESSIVE_LOAD` at the frame header. The request never reaches the gateway. +#[test] +fn direct_h3_oversize_headers_rejected() { + let tag = "w"; + let p = gateway(); + let (auth, path, pad) = (bearer(), format!("/big-req-{tag}"), "~".repeat(7000)); + let names: Vec = (0..10).map(|i| format!("x-pad-{i}")).collect(); + let mut fs = vec![ + (":method", "GET"), + (":scheme", "http"), + (":authority", "127.0.0.1:1"), + (":path", path.as_str()), + ("x-mq-auth", auth.as_str()), + ]; + fs.extend(names.iter().map(|n| (n.as_str(), pad.as_str()))); + let stream = raw_headers(&fs); + assert!(stream.len() > 64 * 1024, "{}", stream.len()); + let c = raw_client(p.server.udp_addr, stream); + { + wait_until("the peer's ConnClosed", || { + !c.handle.lock().closed.is_empty() + }); + assert_eq!(c.handle.lock().closed, [EXCESSIVE_LOAD]); + { + let g = c.handle.lock(); + assert!(g.resets.is_empty(), "{g:?}"); } - stop(c); - assert_eq!(p.join_both(), (0, 0)); } -); + { + assert_not_seen(&p); + } + stop(c); + assert_eq!(p.join_both(), (0, 0)); +} // adoption spec §5.3 (2), server: a request stream that ends without HEADERS. h3wire resets it -// with `H3_REQUEST_INCOMPLETE`; xqc_h3 drops it with no reset and no close (none within -// 500 ms). -receivers!(direct_h3_fin_without_headers, |b: Backend, _tag: &str| { - let p = gateway((Backend::XqcH3, b)); +// with `H3_REQUEST_INCOMPLETE`. +#[test] +fn direct_h3_fin_without_headers() { + let p = gateway(); let c = raw_client(p.server.udp_addr, Vec::new()); - if b == Backend::Wire { + { wait_until("the peer's reset", || !c.handle.lock().resets.is_empty()); { let g = c.handle.lock(); assert_eq!(g.resets[0].1, 0x10d, "{g:?}"); } - } else { - thread::sleep(Duration::from_millis(500)); - { - let g = c.handle.lock(); - assert!(g.resets.is_empty(), "{g:?}"); - } } { let g = c.handle.lock(); @@ -1345,35 +1222,26 @@ receivers!(direct_h3_fin_without_headers, |b: Backend, _tag: &str| { } stop(c); assert_eq!(p.join_both(), (0, 0)); -}); +} // adoption spec §5.3 (2), client: the response stream ends without a final response, after a // bare FIN or after a 103. h3wire aborts it with `H3_MESSAGE_ERROR`, which the fetch client -// answers with 502 `upstream-reset`. xqc_h3 does the same after the bare FIN, but takes the -// 103 for the final response and relays it with an empty body (RFC 9114 §4.1 makes it -// interim). -receivers!( - fetch_response_without_final_fails, - |b: Backend, tag: &str| { - let reset = "HTTP/1.1 502 \r\nX-Mq-Error: upstream-reset\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - let relayed_103 = - "HTTP/1.1 103 \r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n"; - for (i, stream) in [Vec::new(), headers_frame(&[(b":status", b"103")])] - .into_iter() - .enumerate() - { - let (app, _) = RawH3Peer::server(RawH3Script { stream, fin: true }); - let cells = (b, Backend::Raw); - let p = LoopbackProxy::spawn_gateway_against_on(ClientConfig::default(), app, cells); - let target = format!("http://x/no-final-{i}-{tag}"); - let r = fetch_when_up(p.fetch_addr(), &auth(&target, "any")).expect("a reply"); - let want = if b == Backend::XqcH3 && i == 1 { - relayed_103 - } else { - reset - }; - assert_eq!(String::from_utf8_lossy(&r), want, "case {i}"); - assert_eq!(p.join_both(), (0, 0)); - } +// answers with 502 `upstream-reset`. +#[test] +fn fetch_response_without_final_fails() { + let tag = "w"; + let reset = "HTTP/1.1 502 \r\nX-Mq-Error: upstream-reset\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + for (i, stream) in [Vec::new(), headers_frame(&[(b":status", b"103")])] + .into_iter() + .enumerate() + { + let (app, _) = RawH3Peer::server(RawH3Script { stream, fin: true }); + let p = LoopbackProxy::spawn_gateway_against(ClientConfig::default(), app, || { + raw_h3_transport(server_role()) + }); + let target = format!("http://x/no-final-{i}-{tag}"); + let r = fetch_when_up(p.fetch_addr(), &auth(&target, "any")).expect("a reply"); + assert_eq!(String::from_utf8_lossy(&r), reset, "case {i}"); + assert_eq!(p.join_both(), (0, 0)); } -); +} diff --git a/crates/mq-integration/tests/pair_h3.rs b/crates/mq-integration/tests/pair_h3.rs index 5cd63d17..45eb4e87 100644 --- a/crates/mq-integration/tests/pair_h3.rs +++ b/crates/mq-integration/tests/pair_h3.rs @@ -3,42 +3,17 @@ #![forbid(unsafe_code)] use mq_integration::h3_apps::{EchoMode, H3Client, H3EchoServer, H3Handle, H3Script}; -use mq_integration::loopback::{ - Backend, LoopbackPair, cert, h3_transport, raw_h3_transport, transport, -}; -use mq_integration::matrix; +use mq_integration::loopback::{LoopbackPair, cert, h3_transport, raw_h3_transport}; use mq_integration::raw_h3::{RawH3Handle, RawH3Peer, RawH3Script, field, headers_frame}; use mq_runtime::Shard; -use mq_transport_api::Role; +use mq_runtime::testing::{RecordHandle, Recorded, RecordingApp}; +use mq_transport_api::{ConnConfig, ConnProto, Event, H3Header, Role, StreamError}; use std::net::Ipv4Addr; +use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; -/// Bare xqc_h3 on both sides (the rescue tests). -fn pair(mode: EchoMode, script: H3Script) -> LoopbackPair { - let lo = Ipv4Addr::LOCALHOST.into(); - LoopbackPair::spawn( - (lo, lo), - Vec::new(), - move |local| { - let t = transport( - Role::Server { - cert: cert("test.crt"), - key: cert("test.key"), - }, - true, - ); - let (app, h) = H3EchoServer::new(mode); - (Shard::new(t, app, local, 1), h) - }, - move |local, server| { - let (app, h) = H3Client::new(server, script); - (Shard::new(transport(Role::Client, true), app, local, 2), h) - }, - ) -} - -fn post(body: Vec, pause_reads: Option) -> H3Script { +fn post(body: Vec) -> H3Script { H3Script { headers: [ (":method", "POST"), @@ -49,7 +24,6 @@ fn post(body: Vec, pause_reads: Option) -> H3Script { .map(|(n, v)| (n.into(), v.into())) .to_vec(), body, - pause_reads, } } @@ -62,63 +36,6 @@ fn wait(h: &H3Handle, cond: impl Fn(&mq_integration::h3_apps::H3Recorded) -> boo } } -// The rescue tests below read `H3Closed.unread`, which only xqc_h3 fills: they stay on the -// xqc_h3 backend, outside the differential matrix (adoption spec §6.2). - -/// spec §3.7 (2): the client stops reading for longer than 3 PTO after the server's FIN; every -/// byte still arrives, through `H3Closed.unread`. -#[test] -fn h3_unread_rescued_after_close_timer() { - let body: Vec = (0..200_000u32).map(|k| k as u8).collect(); - let p = pair( - EchoMode::Echo, - post(body.clone(), Some(Duration::from_secs(1))), - ); - wait(&p.client.handle, |r| !r.closed.is_empty()); - { - let r = p.client.handle.lock(); - let (close, _) = &r.closed[0]; - assert_eq!(close.stats.stream_err, 0, "{close:?}"); - let u = close.unread.as_ref().expect("rescued"); - assert_eq!(u.body, body); - assert_eq!( - u.headers.as_ref().map(|h| h[0].0.as_slice()), - Some(&b":status"[..]) - ); - assert!(r.body.is_empty() && !r.fin, "the app read nothing itself"); - } - p.join_both(); -} - -/// spec §3.7, §10.3: a server reset before its FIN while the client holds unread body gives -/// `H3Closed` with `stream_err != 0` and no `unread` within 1 RTT + 3 PTO (plus margin). -#[test] -fn h3_reset_with_unread_body_within_margin() { - let p = pair( - EchoMode::ResetBeforeFin, - post(vec![9; 50_000], Some(Duration::from_secs(5))), - ); - wait(&p.client.handle, |r| !r.closed.is_empty()); - let (cut_at, srtt) = { - let s = p.server.handle.lock(); - ( - s.cut_at.expect("server reset"), - Duration::from_micros(s.srtt_us), - ) - }; - { - let r = p.client.handle.lock(); - let (close, at) = &r.closed[0]; - assert_ne!(close.stats.stream_err, 0, "{close:?}"); - assert_eq!(close.unread, None); - // PTO ≤ srtt + 4 × rttvar + max_ack_delay (25 ms), with rttvar ≤ srtt. - let pto = 5 * srtt + Duration::from_millis(25); - let margin = srtt + 3 * pto + Duration::from_millis(500); - assert!(*at - cut_at <= margin, "{:?} > {margin:?}", *at - cut_at); - } - p.join_both(); -} - /// `n` fields of `len` bytes (`x-`). fn fields(n: usize, len: usize) -> Vec<(String, String)> { (0..n) @@ -139,50 +56,41 @@ fn bytes(hs: &[(String, String)]) -> Vec<(Vec, Vec)> { // SP4 spec §5: both engines accept a 40 KiB field section (xquic's default limit was 32 KiB; // `H3_FIELD_SECTION_MAX` is 64 KiB): a request section reaches the server and a response -// section comes back, each exactly as sent. Matrixed over (client, server) backends -// (adoption spec §6.2). -matrix!( - h3_40k_section_both_ways, - |cells: (Backend, Backend), _tag: &str| { - let mut script = post(Vec::new(), None); - script.headers.extend(fields(6, 7000)); - let want_req = bytes(&script.headers); - let mut want_resp = vec![(b":status".to_vec(), b"200".to_vec())]; - want_resp.extend(bytes(&fields(6, 7000))); - let lo = Ipv4Addr::LOCALHOST.into(); - let p = LoopbackPair::spawn( - (lo, lo), - Vec::new(), - move |local| { - let (app, h) = H3EchoServer::new(EchoMode::Echo); - let app = app.with_response_headers(fields(6, 7000)); - ( - Shard::new(h3_transport(server_role(), cells.1), app, local, 1), - h, - ) - }, - move |local, server| { - let (app, h) = H3Client::new(server, script); - ( - Shard::new(h3_transport(Role::Client, cells.0), app, local, 2), - h, - ) - }, - ); - wait(&p.client.handle, |r| r.fin); - { - let s = p.server.handle.lock(); - assert!(section(&want_req) >= 40 * 1024, "{}", section(&want_req)); - assert!(s.request_headers == want_req, "request headers differ"); - } - { - let c = p.client.handle.lock(); - assert!(section(&want_resp) >= 40 * 1024, "{}", section(&want_resp)); - assert!(c.headers == want_resp, "response headers differ"); - } - p.join_both(); +// section comes back, each exactly as sent (adoption spec §6.2). +#[test] +fn h3_40k_section_both_ways() { + let mut script = post(Vec::new()); + script.headers.extend(fields(6, 7000)); + let want_req = bytes(&script.headers); + let mut want_resp = vec![(b":status".to_vec(), b"200".to_vec())]; + want_resp.extend(bytes(&fields(6, 7000))); + let lo = Ipv4Addr::LOCALHOST.into(); + let p = LoopbackPair::spawn( + (lo, lo), + Vec::new(), + move |local| { + let (app, h) = H3EchoServer::new(EchoMode::Echo); + let app = app.with_response_headers(fields(6, 7000)); + (Shard::new(h3_transport(server_role()), app, local, 1), h) + }, + move |local, server| { + let (app, h) = H3Client::new(server, script); + (Shard::new(h3_transport(Role::Client), app, local, 2), h) + }, + ); + wait(&p.client.handle, |r| r.fin); + { + let s = p.server.handle.lock(); + assert!(section(&want_req) >= 40 * 1024, "{}", section(&want_req)); + assert!(s.request_headers == want_req, "request headers differ"); + } + { + let c = p.client.handle.lock(); + assert!(section(&want_resp) >= 40 * 1024, "{}", section(&want_resp)); + assert!(c.headers == want_resp, "response headers differ"); } -); + p.join_both(); +} fn server_role() -> Role { Role::Server { @@ -197,18 +105,18 @@ fn server_role() -> Role { fn wire_pair_smoke() { let body: Vec = (0..1u32 << 20).map(|k| (k % 251) as u8).collect(); let lo = Ipv4Addr::LOCALHOST.into(); - let script = post(body.clone(), None); + let script = post(body.clone()); let p = LoopbackPair::spawn( (lo, lo), Vec::new(), move |local| { let (app, h) = H3EchoServer::new(EchoMode::Echo); - let t = h3_transport(server_role(), Backend::Wire); + let t = h3_transport(server_role()); (Shard::new(t, app, local, 1), h) }, move |local, server| { let (app, h) = H3Client::new(server, script); - let t = h3_transport(Role::Client, Backend::Wire); + let t = h3_transport(Role::Client); (Shard::new(t, app, local, 2), h) }, ); @@ -223,55 +131,250 @@ fn wire_pair_smoke() { } /// adoption spec §6.2: the raw-H3 peer's valid GET (fixed bytes) gets a 200 from an -/// `H3EchoServer` on each backend. +/// `H3EchoServer` over H3Wire. #[test] fn raw_peer_smoke() { - for b in [Backend::XqcH3, Backend::Wire] { - let get = headers_frame(&[ - (b":method", b"GET"), - (b":scheme", b"https"), - (b":authority", b"x"), - (b":path", b"/"), - ]); - let lo = Ipv4Addr::LOCALHOST.into(); - let p: LoopbackPair = LoopbackPair::spawn( - (lo, lo), - Vec::new(), - move |local| { - let (app, h) = H3EchoServer::new(EchoMode::Echo); - (Shard::new(h3_transport(server_role(), b), app, local, 1), h) - }, - move |local, server| { - let s = RawH3Script { - stream: get, - fin: true, - }; - let (app, h) = RawH3Peer::client(server, s); - (Shard::new(raw_h3_transport(Role::Client), app, local, 2), h) - }, + let get = headers_frame(&[ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", b"x"), + (b":path", b"/"), + ]); + let lo = Ipv4Addr::LOCALHOST.into(); + let p: LoopbackPair = LoopbackPair::spawn( + (lo, lo), + Vec::new(), + move |local| { + let (app, h) = H3EchoServer::new(EchoMode::Echo); + (Shard::new(h3_transport(server_role()), app, local, 1), h) + }, + move |local, server| { + let s = RawH3Script { + stream: get, + fin: true, + }; + let (app, h) = RawH3Peer::client(server, s); + (Shard::new(raw_h3_transport(Role::Client), app, local, 2), h) + }, + ); + let end = Instant::now() + Duration::from_secs(10); + while !p.client.handle.lock().fin { + assert!( + Instant::now() < end, + "timed out: {:?}", + p.client.handle.lock() + ); + thread::sleep(Duration::from_millis(5)); + } + { + let seen = p.client.handle.lock(); + assert_eq!( + field(&seen.read, b":status").as_deref(), + Some(&b"200"[..]), + "{seen:?}" + ); + assert!(seen.resets.is_empty() && seen.closed.is_empty(), "{seen:?}"); + } + assert_eq!(p.server.handle.lock().requests, 1); + p.join_both(); +} + +/// Two live H3Wire requests: an unread partial response and an unfinished request. +fn live_wire_pair( + idle: Option, + close_on_response: bool, +) -> (LoopbackPair, mpsc::Receiver<()>) { + let (ready_tx, ready_rx) = mpsc::channel(); + let lo = Ipv4Addr::LOCALHOST.into(); + let p = LoopbackPair::spawn( + (lo, lo), + Vec::new(), + move |local| { + let (app, rec) = RecordingApp::new(); + rec.on(|r, cx| match r { + Recorded::TransportEvent(Event::H3Readable(req)) => { + let fin = cx.h3_recv_headers(*req, &mut |_, _| {}); + if fin == Ok(true) { + let headers = [H3Header { + name: b":status", + value: b"200", + }]; + assert_eq!(cx.h3_send_headers(*req, &headers, false), Ok(())); + assert_eq!(cx.h3_send_body(*req, b"partial", false), Ok(7)); + } + } + Recorded::Shutdown => cx.request_exit(0), + _ => {} + }); + (Shard::new(h3_transport(server_role()), app, local, 1), rec) + }, + move |local, server| { + let (app, rec) = RecordingApp::new(); + let mut prefix_read = false; + rec.on(move |r, cx| match r { + Recorded::Start => { + cx.connect(&ConnConfig { + peer: server, + sni: "mqproxy", + idle_timeout: idle, + proto: ConnProto::H3, + }) + .expect("connect"); + } + Recorded::TransportEvent(Event::ConnEstablished(conn)) => { + let headers = [ + H3Header { + name: b":method", + value: b"POST", + }, + H3Header { + name: b":scheme", + value: b"https", + }, + H3Header { + name: b":authority", + value: b"x", + }, + H3Header { + name: b":path", + value: b"/", + }, + ]; + for fin in [true, false] { + let req = cx.open_h3_request(*conn).expect("open request"); + assert_eq!(cx.h3_send_headers(req, &headers, fin), Ok(())); + } + } + Recorded::TransportEvent(Event::H3Readable(req)) if !prefix_read => { + let _ = cx.h3_recv_headers(*req, &mut |_, _| {}); + let mut prefix = [0; 1]; + match cx.h3_recv_body(*req, &mut prefix) { + Ok((n, fin)) => { + assert_eq!((n, fin), (1, false), "positive body prefix, no FIN"); + assert_eq!(&prefix, b"p"); + // Only one of the seven sent bytes is consumed; six stay unread. + prefix_read = true; + ready_tx.send(()).expect("test awaiting body prefix"); + if close_on_response { + let conn = cx.h3_req_info(*req).expect("live request").conn; + cx.close_conn(conn); + } + } + Err(StreamError::Blocked) => {} // HEADERS may precede DATA. + Err(e) => panic!("body prefix: {e:?}"), + } + } + Recorded::Shutdown => cx.request_exit(0), + _ => {} + }); + (Shard::new(h3_transport(Role::Client), app, local, 2), rec) + }, + ); + (p, ready_rx) +} + +fn wait_records(rec: &RecordHandle, pred: impl Fn(&[Recorded]) -> bool) { + let end = Instant::now() + Duration::from_secs(10); + while !pred(&rec.records()) { + assert!(Instant::now() < end, "timed out: {:?}", rec.records()); + thread::sleep(Duration::from_millis(2)); + } +} + +/// The peer disappears mid-response: the real engine's idle timeout closes live requests. +#[test] +fn wire_idle_timeout_mid_response_aborts() { + let idle = Duration::from_secs(1); + let (mut p, ready) = live_wire_pair(Some(idle), false); + ready + .recv_timeout(Duration::from_secs(10)) + .expect("unfinished body prefix received"); + let start = Instant::now(); + // The server exits without sending CONNECTION_CLOSE, so only idle expiry can close it. + p.server.shutdown.trigger(); + assert_eq!(p.server.join_timeout(Duration::from_secs(10)), Some(0)); + wait_records(&p.client.handle, |records| { + records + .iter() + .any(|r| matches!(r, Recorded::TransportEvent(Event::ConnClosed(..)))) + }); + assert!(start.elapsed() <= 2 * idle, "after {:?}", start.elapsed()); + let records = p.client.handle.records(); + let closed: Vec<_> = records + .iter() + .filter_map(|r| match r { + Recorded::TransportEvent(Event::H3Closed(_, close)) => Some(close), + _ => None, + }) + .collect(); + assert_eq!(closed.len(), 2, "{records:?}"); + p.client.shutdown.trigger(); + assert_eq!(p.client.join(), 0); +} + +/// Local close reports every live request before ConnClosed. +#[test] +fn wire_local_close_with_live_requests_is_clean() { + let (p, ready) = live_wire_pair(None, true); + ready + .recv_timeout(Duration::from_secs(10)) + .expect("unfinished body prefix received before local close"); + wait_records(&p.client.handle, |records| { + records + .iter() + .any(|r| matches!(r, Recorded::TransportEvent(Event::ConnClosed(..)))) + }); + let records = p.client.handle.records(); + let conn = records + .iter() + .position(|r| matches!(r, Recorded::TransportEvent(Event::ConnClosed(..)))) + .expect("ConnClosed"); + let closed: Vec<_> = records + .iter() + .enumerate() + .filter_map(|(i, r)| match r { + Recorded::TransportEvent(Event::H3Closed(_, close)) => Some((i, close)), + _ => None, + }) + .collect(); + assert_eq!(closed.len(), 2, "{records:?}"); + for (i, _) in closed { + assert!(i < conn, "{records:?}"); + } + assert_eq!(p.join_both(), (0, 0)); +} + +/// Engine teardown with open requests on both sides, including an unread partial response. +#[test] +fn wire_drop_transport_with_live_requests_is_clean() { + let (p, ready) = live_wire_pair(None, false); + ready + .recv_timeout(Duration::from_secs(10)) + .expect("unfinished body prefix received"); + wait_records(&p.server.handle, |records| { + records + .iter() + .filter(|r| matches!(r, Recorded::TransportEvent(Event::H3Request(..)))) + .count() + == 2 + }); + let server = p.server.handle.records(); + assert_eq!( + server + .iter() + .filter(|r| matches!(r, Recorded::TransportEvent(Event::H3Request(..)))) + .count(), + 2, + "{server:?}" + ); + for rec in [&p.client.handle, &p.server.handle] { + assert!( + !rec.records().iter().any(|r| matches!( + r, + Recorded::TransportEvent(Event::H3Closed(..) | Event::ConnClosed(..)) + )), + "requests still live" ); - let end = Instant::now() + Duration::from_secs(10); - while !p.client.handle.lock().fin { - assert!( - Instant::now() < end, - "{b:?}: timed out: {:?}", - p.client.handle.lock() - ); - thread::sleep(Duration::from_millis(5)); - } - { - let seen = p.client.handle.lock(); - assert_eq!( - field(&seen.read, b":status").as_deref(), - Some(&b"200"[..]), - "{b:?}: {seen:?}" - ); - assert!( - seen.resets.is_empty() && seen.closed.is_empty(), - "{b:?}: {seen:?}" - ); - } - assert_eq!(p.server.handle.lock().requests, 1, "{b:?}"); - p.join_both(); } + assert_eq!(p.join_both(), (0, 0)); } diff --git a/crates/mq-integration/tests/pair_mitm.rs b/crates/mq-integration/tests/pair_mitm.rs index 26cd5b2b..46cb8dc6 100644 --- a/crates/mq-integration/tests/pair_mitm.rs +++ b/crates/mq-integration/tests/pair_mitm.rs @@ -6,8 +6,7 @@ use mq_integration::browser::{Body, Resp, TestBrowser, roots}; use mq_integration::log_tap; -use mq_integration::loopback::{Backend, LoopbackProxy}; -use mq_integration::matrix; +use mq_integration::loopback::LoopbackProxy; use mq_integration::origin_server::{ Handler, ORIGIN_CA, ORIGIN_CRT, OriginServer, OriginServerMode, Proto, }; @@ -67,7 +66,7 @@ fn p256() -> MitmConfig { } /// The pair, its `TRANSPARENT` accepts stamped with `origin` (R3). -fn proxy(origin: SocketAddr, mitm: MitmConfig, cells: (Backend, Backend)) -> LoopbackProxy { +fn proxy(origin: SocketAddr, mitm: MitmConfig) -> LoopbackProxy { // The routing decisions are logged at debug (SP4 spec §7.10). Once: `install` resets // the level to Info, which would race a parallel test's debug line. static TAP: Once = Once::new(); @@ -84,7 +83,7 @@ fn proxy(origin: SocketAddr, mitm: MitmConfig, cells: (Backend, Backend)) -> Loo token: TOKEN.into(), ..ServerConfig::default() }; - LoopbackProxy::spawn_mitm_on(client, server, Path::new(ORIGIN_CA), mitm, origin, cells) + LoopbackProxy::spawn_mitm(client, server, Path::new(ORIGIN_CA), mitm, origin) } fn origin(proto: Proto, handler: Handler) -> OriginServer { @@ -140,12 +139,10 @@ fn wait_until(what: &str, cond: impl Fn() -> bool) { } } -/// The cells of every test that waits for a log line run one at a time: lines accumulate -/// across the tests of this binary, and each opaque reason is produced by one test only, -/// but by every cell of it. +/// Tests that wait for a log line run one at a time: lines accumulate across this binary. static SERIAL: Mutex<()> = Mutex::new(()); -/// A log marker one cell waits for, with `SERIAL` held from before the cell starts. +/// A log marker a test waits for, with `SERIAL` held from before the test starts. struct Logged { marker: &'static str, before: usize, @@ -203,206 +200,188 @@ fn header<'a>(h: &'a [(String, Vec)], name: &str) -> Vec<&'a [u8]> { // ---- data ---- // SP4 spec §11.3: a byte-exact 8 MiB download, from an h1 and an h2 TLS origin. -matrix!( - mitm_8mib_download_byte_exact_h1_and_h2_origin, - |cells: (Backend, Backend), _tag: &str| { - let len = 8 * MIB; - for proto in [Proto::H1Tls, Proto::H2Tls] { - let o = origin(proto, Handler::FileBytes(len as u64)); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let (status, h, body) = b.get("/dl", &[]); - assert_eq!(status, 200); - assert_eq!(h.get("content-length").unwrap(), "8388608"); - assert!(body == pattern(len), "body differs ({} bytes)", body.len()); - assert_eq!(p.join_both(), (0, 0)); - } +#[test] +fn mitm_8mib_download_byte_exact_h1_and_h2_origin() { + let len = 8 * MIB; + for proto in [Proto::H1Tls, Proto::H2Tls] { + let o = origin(proto, Handler::FileBytes(len as u64)); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let (status, h, body) = b.get("/dl", &[]); + assert_eq!(status, 200); + assert_eq!(h.get("content-length").unwrap(), "8388608"); + assert!(body == pattern(len), "body differs ({} bytes)", body.len()); + assert_eq!(p.join_both(), (0, 0)); } -); +} // SP4 spec §7.5 step 6, §11.3: 8 MiB uploads echoed back, with a known length (forwarded // as `content-length`) and streamed (none). -matrix!( - mitm_8mib_upload_known_cl_and_streaming, - |cells: (Backend, Backend), _tag: &str| { - let o = origin(Proto::H2Tls, Handler::Echo); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let data = pattern(8 * MIB); - for (path, known) in [("/known", true), ("/streaming", false)] { - let (status, _, body) = b.post(path, &[], data.clone(), known); - assert_eq!(status, 200, "{path}"); - assert!(body == data, "{path}: echo differs ({} bytes)", body.len()); - } - let reqs = o.requests(); - let cl = |path: &str| { - let r = reqs.iter().find(|r| r.path == path).expect("request"); - header(&r.headers, "content-length").concat() - }; - assert_eq!(cl("/known"), b"8388608"); - assert_eq!(cl("/streaming"), b""); - assert_eq!(p.join_both(), (0, 0)); +#[test] +fn mitm_8mib_upload_known_cl_and_streaming() { + let o = origin(Proto::H2Tls, Handler::Echo); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let data = pattern(8 * MIB); + for (path, known) in [("/known", true), ("/streaming", false)] { + let (status, _, body) = b.post(path, &[], data.clone(), known); + assert_eq!(status, 200, "{path}"); + assert!(body == data, "{path}: echo differs ({} bytes)", body.len()); } -); + let reqs = o.requests(); + let cl = |path: &str| { + let r = reqs.iter().find(|r| r.path == path).expect("request"); + header(&r.headers, "content-length").concat() + }; + assert_eq!(cl("/known"), b"8388608"); + assert_eq!(cl("/streaming"), b""); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §11.3: 16 concurrent streams on one browser connection. -matrix!( - mitm_16_parallel_streams, - |cells: (Backend, Backend), _tag: &str| { - let len = 256 * 1024; - let o = origin(Proto::H2Tls, Handler::FileBytes(len as u64)); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let all = b.get_parallel(16, "/p"); - assert_eq!(all.len(), 16); - for (status, _, body) in all { - assert_eq!(status, 200); - assert!(body == pattern(len), "body differs ({} bytes)", body.len()); - } - assert_eq!(p.join_both(), (0, 0)); +#[test] +fn mitm_16_parallel_streams() { + let len = 256 * 1024; + let o = origin(Proto::H2Tls, Handler::FileBytes(len as u64)); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let all = b.get_parallel(16, "/p"); + assert_eq!(all.len(), 16); + for (status, _, body) in all { + assert_eq!(status, 200); + assert!(body == pattern(len), "body differs ({} bytes)", body.len()); } -); + assert_eq!(p.join_both(), (0, 0)); +} // ---- request and response shape ---- // SP4 spec §7.5 step 4: a 6 KiB cookie sent as crumbs arrives as one joined field at an // h1 origin. -matrix!( - mitm_split_cookie_joined_at_h1_origin, - |cells: (Backend, Backend), _tag: &str| { - let o = origin(Proto::H1Tls, Handler::Status(200)); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let crumbs: Vec = (0..6) - .map(|i| format!("c{i}={}", "x".repeat(1020))) - .collect(); - let hs: Vec<(&str, &[u8])> = crumbs.iter().map(|c| ("cookie", c.as_bytes())).collect(); - assert_eq!(b.get("/cookie", &hs).0, 200); - let reqs = o.requests(); - let r = reqs.iter().find(|r| r.path == "/cookie").expect("request"); - assert_eq!(header(&r.headers, "cookie"), [crumbs.join("; ").as_bytes()]); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn mitm_split_cookie_joined_at_h1_origin() { + let o = origin(Proto::H1Tls, Handler::Status(200)); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let crumbs: Vec = (0..6) + .map(|i| format!("c{i}={}", "x".repeat(1020))) + .collect(); + let hs: Vec<(&str, &[u8])> = crumbs.iter().map(|c| ("cookie", c.as_bytes())).collect(); + assert_eq!(b.get("/cookie", &hs).0, 200); + let reqs = o.requests(); + let r = reqs.iter().find(|r| r.path == "/cookie").expect("request"); + assert_eq!(header(&r.headers, "cookie"), [crumbs.join("; ").as_bytes()]); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §5, §11.3: a 4 KiB `:path` and a 6 KiB CSP response header pass. -matrix!( - mitm_4k_path_and_6k_csp, - |cells: (Backend, Backend), _tag: &str| { - let csp = "a".repeat(6 * 1024); - let h = Handler::WithHeaders( - vec![("content-security-policy", csp.clone())], - Box::new(Handler::Status(200)), - ); - let o = origin(Proto::H2Tls, h); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let path = format!("/long?q={}", "q".repeat(4 * 1024)); - let (status, h, _) = b.get(&path, &[]); - assert_eq!(status, 200); - assert_eq!(h.get("content-security-policy").unwrap(), &csp[..]); - assert!(o.requests().iter().any(|r| r.path == path), "path not seen"); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn mitm_4k_path_and_6k_csp() { + let csp = "a".repeat(6 * 1024); + let h = Handler::WithHeaders( + vec![("content-security-policy", csp.clone())], + Box::new(Handler::Status(200)), + ); + let o = origin(Proto::H2Tls, h); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let path = format!("/long?q={}", "q".repeat(4 * 1024)); + let (status, h, _) = b.get(&path, &[]); + assert_eq!(status, 200); + assert_eq!(h.get("content-security-policy").unwrap(), &csp[..]); + assert!(o.requests().iter().any(|r| r.path == path), "path not seen"); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §5 "Methods", "Empty header values": both reach the origin unchanged. -matrix!( - mitm_lowercase_method_and_empty_header_reach_origin, - |cells: (Backend, Backend), _tag: &str| { - let o = origin(Proto::H1Tls, Handler::Status(200)); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let r = b.request("purgeit", None, "/m", &[("x-empty", b"")], Body::Empty); - assert_eq!(r.expect("request").0, 200); - let reqs = o.requests(); - let r = reqs.iter().find(|r| r.path == "/m").expect("request"); - assert_eq!(r.method, "purgeit"); - assert_eq!(header(&r.headers, "x-empty"), [b""]); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn mitm_lowercase_method_and_empty_header_reach_origin() { + let o = origin(Proto::H1Tls, Handler::Status(200)); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let r = b.request("purgeit", None, "/m", &[("x-empty", b"")], Body::Empty); + assert_eq!(r.expect("request").0, 200); + let reqs = o.requests(); + let r = reqs.iter().find(|r| r.path == "/m").expect("request"); + assert_eq!(r.method, "purgeit"); + assert_eq!(header(&r.headers, "x-empty"), [b""]); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §5 "Browser h2 side" and "Errors": a browser section over `SECTION_MAX` gets // h2's own 431 or a reset; a rendered head over a limit gets 400 `header-too-long`; a // response over a limit gets 502 `upstream-protocol`. -matrix!( - mitm_over_limit_400_or_431_and_502, - |cells: (Backend, Backend), _tag: &str| { - let h = Handler::PerPath(vec![("/big-resp", Handler::HeaderListBytes(9000))]); - let o = origin(Proto::H2Tls, h); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); +#[test] +fn mitm_over_limit_400_or_431_and_502() { + let h = Handler::PerPath(vec![("/big-resp", Handler::HeaderListBytes(9000))]); + let o = origin(Proto::H2Tls, h); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + + // 5 × 7000 bytes: every field within FIELD_MAX, the section over SECTION_MAX. + let v = vec![b'v'; 7000]; + let names: Vec = (0..5).map(|i| format!("x-big{i}")).collect(); + let hs: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &v[..])).collect(); + match b.request("GET", None, "/section", &hs, Body::Empty) { + Ok(r) => assert_eq!(r.0, 431), + Err(e) => assert!(e.is_reset(), "{e}"), + } - // 5 × 7000 bytes: every field within FIELD_MAX, the section over SECTION_MAX. - let v = vec![b'v'; 7000]; - let names: Vec = (0..5).map(|i| format!("x-big{i}")).collect(); - let hs: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &v[..])).collect(); - match b.request("GET", None, "/section", &hs, Body::Empty) { - Ok(r) => assert_eq!(r.0, 431), - Err(e) => assert!(e.is_reset(), "{e}"), - } + // One 9000-byte field: under SECTION_MAX for h2, over FIELD_MAX for the render. + let v = vec![b'v'; 9000]; + let r = b.request("GET", None, "/field", &[("x-big", &v)], Body::Empty); + let r = r.expect("request"); + assert_eq!( + (r.0.as_u16(), xmq_error(&r)), + (400, Some("header-too-long")) + ); - // One 9000-byte field: under SECTION_MAX for h2, over FIELD_MAX for the render. - let v = vec![b'v'; 9000]; - let r = b.request("GET", None, "/field", &[("x-big", &v)], Body::Empty); - let r = r.expect("request"); - assert_eq!( - (r.0.as_u16(), xmq_error(&r)), - (400, Some("header-too-long")) - ); - - let r = b.get("/big-resp", &[]); - assert_eq!( - (r.0.as_u16(), xmq_error(&r)), - (502, Some("upstream-protocol")) - ); - assert!( - !o.requests() - .iter() - .any(|r| r.path == "/section" || r.path == "/field") - ); - assert_eq!(p.join_both(), (0, 0)); - } -); + let r = b.get("/big-resp", &[]); + assert_eq!( + (r.0.as_u16(), xmq_error(&r)), + (502, Some("upstream-protocol")) + ); + assert!( + !o.requests() + .iter() + .any(|r| r.path == "/section" || r.path == "/field") + ); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §7.7 (D9): `alt-svc` is dropped; other headers stay. -matrix!( - mitm_alt_svc_stripped, - |cells: (Backend, Backend), _tag: &str| { - let h = Handler::WithHeaders( - vec![ - ("alt-svc", "h3=\":443\"; ma=86400".into()), - ("x-kept", "yes".into()), - ], - Box::new(Handler::Status(200)), - ); - let o = origin(Proto::H2Tls, h); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let (status, h, _) = b.get("/alt", &[]); - assert_eq!(status, 200); - assert_eq!(h.get("x-kept").unwrap(), "yes"); - assert!(h.get("alt-svc").is_none(), "{h:?}"); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn mitm_alt_svc_stripped() { + let h = Handler::WithHeaders( + vec![ + ("alt-svc", "h3=\":443\"; ma=86400".into()), + ("x-kept", "yes".into()), + ], + Box::new(Handler::Status(200)), + ); + let o = origin(Proto::H2Tls, h); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let (status, h, _) = b.get("/alt", &[]); + assert_eq!(status, 200); + assert_eq!(h.get("x-kept").unwrap(), "yes"); + assert!(h.get("alt-svc").is_none(), "{h:?}"); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §7.5 step 3: `:authority` ≠ SNI → 421, no `x-mq-error`, nothing forwarded. -matrix!( - mitm_421_on_authority_mismatch, - |cells: (Backend, Backend), _tag: &str| { - let o = origin(Proto::H2Tls, Handler::Status(200)); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - let other = format!("other.localhost:{}", o.addr.port()); - let r = b.request("GET", Some(&other), "/misdirected", &[], Body::Empty); - let r = r.expect("request"); - assert_eq!((r.0.as_u16(), xmq_error(&r)), (421, None)); - assert!(!o.requests().iter().any(|r| r.path == "/misdirected")); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn mitm_421_on_authority_mismatch() { + let o = origin(Proto::H2Tls, Handler::Status(200)); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + let other = format!("other.localhost:{}", o.addr.port()); + let r = b.request("GET", Some(&other), "/misdirected", &[], Body::Empty); + let r = r.expect("request"); + assert_eq!((r.0.as_u16(), xmq_error(&r)), (421, None)); + assert!(!o.requests().iter().any(|r| r.path == "/misdirected")); + assert_eq!(p.join_both(), (0, 0)); +} // ---- tunnel ---- @@ -441,143 +420,130 @@ fn during_outage(b: &TestBrowser, path: &str, unavailable: &Cell) -> Outag // SP4 spec §10: with the tunnel down, a request gets 502 `tunnel-unavailable` and the // browser connection stays usable. -matrix!( - mitm_tunnel_down_502, - |cells: (Backend, Backend), _tag: &str| { - let o = origin(Proto::H2Tls, Handler::Status(200)); - let p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - assert_eq!(b.get("/before", &[]).0, 200); - p.server.shutdown.trigger(); - let seen = Cell::new(false); - wait_until("502 tunnel-unavailable", || { - during_outage(&b, "/down", &seen) == Outage::Unavailable - }); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn mitm_tunnel_down_502() { + let o = origin(Proto::H2Tls, Handler::Status(200)); + let p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + assert_eq!(b.get("/before", &[]).0, 200); + p.server.shutdown.trigger(); + let seen = Cell::new(false); + wait_until("502 tunnel-unavailable", || { + during_outage(&b, "/down", &seen) == Outage::Unavailable + }); + assert_eq!(p.join_both(), (0, 0)); +} // Review Focus 1: the tunnel drops (server restart) while a MITM stream is in flight. // That stream ends with 502 `upstream-reset` (SP4 spec §10) or a stream reset; requests during the outage follow // `during_outage`'s order; once the tunnel is back, the same browser // connection is served on it. `TestBrowser` never redials, so a success on its one // `SendRequest` after the restart is that connection. -matrix!( - mitm_survives_tunnel_reconnect, - |cells: (Backend, Backend), _tag: &str| { - let h = Handler::PerPath(vec![ - ("/hang", Handler::HangNoResponse), - ("/before", Handler::Status(200)), - ("/after", Handler::Status(200)), - ]); - let o = origin(Proto::H2Tls, h); - let mut p = proxy(o.addr, p256(), cells); - let b = mitm_browser(&p, o.addr); - assert_eq!(b.get("/before", &[]).0, 200); - - let hang = b.start_get("/hang"); - b.drive_until(|| o.requests().iter().any(|r| r.path == "/hang")); - p.restart_server(); - match b.finish(hang) { - Ok(r) => assert_eq!((r.0.as_u16(), xmq_error(&r)), (502, Some("upstream-reset"))), - Err(e) => assert!(e.is_reset() && !e.is_go_away() && !e.is_io(), "{e}"), - } - - let seen = Cell::new(false); - wait_until("a 200 on the new tunnel", || { - during_outage(&b, "/after", &seen) == Outage::Served - }); - assert_eq!(p.join_both(), (0, 0)); +#[test] +fn mitm_survives_tunnel_reconnect() { + let h = Handler::PerPath(vec![ + ("/hang", Handler::HangNoResponse), + ("/before", Handler::Status(200)), + ("/after", Handler::Status(200)), + ]); + let o = origin(Proto::H2Tls, h); + let mut p = proxy(o.addr, p256()); + let b = mitm_browser(&p, o.addr); + assert_eq!(b.get("/before", &[]).0, 200); + + let hang = b.start_get("/hang"); + b.drive_until(|| o.requests().iter().any(|r| r.path == "/hang")); + p.restart_server(); + match b.finish(hang) { + Ok(r) => assert_eq!((r.0.as_u16(), xmq_error(&r)), (502, Some("upstream-reset"))), + Err(e) => assert!(e.is_reset() && !e.is_go_away() && !e.is_io(), "{e}"), } -); + + let seen = Cell::new(false); + wait_until("a 200 on the new tunnel", || { + during_outage(&b, "/after", &seen) == Outage::Served + }); + assert_eq!(p.join_both(), (0, 0)); +} // ---- opaque routes: the origin's own certificate, verified with the origin CA ---- // SP4 spec §7.3 route step 3: no `h2` in ALPN → opaque. -matrix!(opaque_no_h2_alpn, |cells: (Backend, Backend), - _tag: &str| { +#[test] +fn opaque_no_h2_alpn() { let log = Logged::start("opaque(NoH2)"); let o = origin(Proto::H2Tls, Handler::Status(200)); - let p = proxy(o.addr, p256(), cells); + let p = proxy(o.addr, p256()); let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); assert_origin_chain(&b.tls_only(&[b"http/1.1"], "localhost")); log.wait(); assert_eq!(p.join_both(), (0, 0)); -}); +} // SP4 spec §7.3 route step 4: an ignored host → opaque; h2 then runs with the origin. -matrix!( - opaque_ignored_host, - |cells: (Backend, Backend), _tag: &str| { - let log = Logged::start("opaque(Ignored)"); - let o = origin(Proto::H2Tls, Handler::Status(200)); - let p = proxy( - o.addr, - mitm_cfg("ca-p256", &["localhost"], MitmTuning::default()), - cells, - ); - let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); - assert_origin_chain(&b.tls_only(&[b"h2"], "localhost")); - assert_eq!(b.get("/direct", &[]).0, 200); - assert!(o.requests().iter().any(|r| r.path == "/direct")); - log.wait(); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn opaque_ignored_host() { + let log = Logged::start("opaque(Ignored)"); + let o = origin(Proto::H2Tls, Handler::Status(200)); + let p = proxy( + o.addr, + mitm_cfg("ca-p256", &["localhost"], MitmTuning::default()), + ); + let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); + assert_origin_chain(&b.tls_only(&[b"h2"], "localhost")); + assert_eq!(b.get("/direct", &[]).0, 200); + assert!(o.requests().iter().any(|r| r.path == "/direct")); + log.wait(); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §7.3 "Peek": bytes that are not TLS → opaque, relayed as they are. -matrix!( - opaque_non_tls_bytes, - |cells: (Backend, Backend), _tag: &str| { - let log = Logged::start("opaque(NotTls)"); - let o = origin(Proto::H1Plain, Handler::Status(200)); - let p = proxy(o.addr, p256(), cells); - let mut s = TcpStream::connect(p.mitm_addr()).unwrap(); - s.set_read_timeout(Some(T)).unwrap(); - s.write_all(b"GET /plain HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") - .unwrap(); - let mut reply = Vec::new(); - s.read_to_end(&mut reply).unwrap(); - assert!(reply.starts_with(b"HTTP/1.1 200"), "{reply:?}"); - log.wait(); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn opaque_non_tls_bytes() { + let log = Logged::start("opaque(NotTls)"); + let o = origin(Proto::H1Plain, Handler::Status(200)); + let p = proxy(o.addr, p256()); + let mut s = TcpStream::connect(p.mitm_addr()).unwrap(); + s.set_read_timeout(Some(T)).unwrap(); + s.write_all(b"GET /plain HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut reply = Vec::new(); + s.read_to_end(&mut reply).unwrap(); + assert!(reply.starts_with(b"HTTP/1.1 200"), "{reply:?}"); + log.wait(); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §7.3 route step 5: an SNI outside a NameConstraints CA's scope → opaque. -matrix!( - opaque_out_of_scope_constrained_ca, - |cells: (Backend, Backend), _tag: &str| { - let log = Logged::start("opaque(OutOfCaScope)"); - let o = origin(Proto::H2Tls, Handler::Status(200)); - let mitm = mitm_cfg("ca-dns-constraint", &[], MitmTuning::default()); - let p = proxy(o.addr, mitm, cells); - let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); - assert_origin_chain(&b.tls_only(&[b"h2"], "localhost")); - log.wait(); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn opaque_out_of_scope_constrained_ca() { + let log = Logged::start("opaque(OutOfCaScope)"); + let o = origin(Proto::H2Tls, Handler::Status(200)); + let mitm = mitm_cfg("ca-dns-constraint", &[], MitmTuning::default()); + let p = proxy(o.addr, mitm); + let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); + assert_origin_chain(&b.tls_only(&[b"h2"], "localhost")); + log.wait(); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §7.3: no ClientHello within the peek deadline → opaque; a handshake started // afterwards reaches the origin. -matrix!( - opaque_peek_timeout, - |cells: (Backend, Backend), _tag: &str| { - let log = Logged::start("opaque(Timeout)"); - let o = origin(Proto::H2Tls, Handler::Status(200)); - let tuning = MitmTuning { - peek: Duration::from_millis(200), - ..MitmTuning::default() - }; - let p = proxy(o.addr, mitm_cfg("ca-p256", &[], tuning), cells); - let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); - let tcp = TcpStream::connect(p.mitm_addr()).unwrap(); - log.wait(); - assert_origin_chain(&b.tls_on(tcp, &[b"h2"], "localhost")); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn opaque_peek_timeout() { + let log = Logged::start("opaque(Timeout)"); + let o = origin(Proto::H2Tls, Handler::Status(200)); + let tuning = MitmTuning { + peek: Duration::from_millis(200), + ..MitmTuning::default() + }; + let p = proxy(o.addr, mitm_cfg("ca-p256", &[], tuning)); + let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); + let tcp = TcpStream::connect(p.mitm_addr()).unwrap(); + log.wait(); + assert_origin_chain(&b.tls_on(tcp, &[b"h2"], "localhost")); + assert_eq!(p.join_both(), (0, 0)); +} /// A plain TCP origin that reads to EOF, writes back what it read, and closes. fn echo_to_eof() -> (SocketAddr, JoinHandle>) { @@ -596,44 +562,40 @@ fn echo_to_eof() -> (SocketAddr, JoinHandle>) { // SP4 spec §7.3 "EOF and errors while peeking": a half-close with bytes in rx → opaque; // the relay carries the bytes and the EOF, and the answer still comes back. -matrix!( - opaque_peek_eof_half_close, - |cells: (Backend, Backend), _tag: &str| { - let log = Logged::start("opaque(Eof)"); - let (addr, origin) = echo_to_eof(); - let p = proxy(addr, p256(), cells); - let mut s = TcpStream::connect(p.mitm_addr()).unwrap(); - s.set_read_timeout(Some(T)).unwrap(); - // A TLS record header prefix: the peek waits for more, then sees the EOF. - let sent = [0x16, 0x03, 0x01, 0x00]; - s.write_all(&sent).unwrap(); - s.shutdown(Shutdown::Write).unwrap(); - let mut reply = Vec::new(); - s.read_to_end(&mut reply).unwrap(); - assert_eq!(reply, sent); - assert_eq!(origin.join().unwrap(), sent); - log.wait(); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn opaque_peek_eof_half_close() { + let log = Logged::start("opaque(Eof)"); + let (addr, origin) = echo_to_eof(); + let p = proxy(addr, p256()); + let mut s = TcpStream::connect(p.mitm_addr()).unwrap(); + s.set_read_timeout(Some(T)).unwrap(); + // A TLS record header prefix: the peek waits for more, then sees the EOF. + let sent = [0x16, 0x03, 0x01, 0x00]; + s.write_all(&sent).unwrap(); + s.shutdown(Shutdown::Write).unwrap(); + let mut reply = Vec::new(); + s.read_to_end(&mut reply).unwrap(); + assert_eq!(reply, sent); + assert_eq!(origin.join().unwrap(), sent); + log.wait(); + assert_eq!(p.join_both(), (0, 0)); +} // SP4 spec §7.3 "Accept" step 2: at `max_conns` (lowered to 1) a new flow goes opaque. -matrix!( - opaque_at_capacity, - |cells: (Backend, Backend), _tag: &str| { - let log = Logged::start("opaque(AtCapacity)"); - let o = origin(Proto::H2Tls, Handler::Status(200)); - let tuning = MitmTuning { - max_conns: 1, - ..MitmTuning::default() - }; - let p = proxy(o.addr, mitm_cfg("ca-p256", &[], tuning), cells); - // The one MITM conn, live. - let held = mitm_browser(&p, o.addr); - let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); - assert_origin_chain(&b.tls_only(&[b"h2"], "localhost")); - assert_eq!(held.get("/still", &[]).0, 200); - log.wait(); - assert_eq!(p.join_both(), (0, 0)); - } -); +#[test] +fn opaque_at_capacity() { + let log = Logged::start("opaque(AtCapacity)"); + let o = origin(Proto::H2Tls, Handler::Status(200)); + let tuning = MitmTuning { + max_conns: 1, + ..MitmTuning::default() + }; + let p = proxy(o.addr, mitm_cfg("ca-p256", &[], tuning)); + // The one MITM conn, live. + let held = mitm_browser(&p, o.addr); + let b = browser(&p, o.addr, Path::new(ORIGIN_CA)); + assert_origin_chain(&b.tls_only(&[b"h2"], "localhost")); + assert_eq!(held.get("/still", &[]).0, 200); + log.wait(); + assert_eq!(p.join_both(), (0, 0)); +} diff --git a/crates/mq-proxy/src/client/exchange/mod.rs b/crates/mq-proxy/src/client/exchange/mod.rs index a0313f1a..103f4c8c 100644 --- a/crates/mq-proxy/src/client/exchange/mod.rs +++ b/crates/mq-proxy/src/client/exchange/mod.rs @@ -7,7 +7,7 @@ pub(crate) mod wire; use mq_http::headers::{HttpVer, Method, Reject, Target, body_check_applies}; use mq_runtime::Cx; -use mq_transport_api::{ConnId, Event, H3Header, H3ReqId, StreamError, Unread}; +use mq_transport_api::{ConnId, Event, H3Header, H3ReqId, StreamError}; use resp::{HeadCollector, RespHead}; use std::collections::HashMap; @@ -97,8 +97,8 @@ enum Up { /// SP4 spec §4.2: the response side. enum Down { AwaitHead, - /// `H3Closed` arrived first: rescue or none. - ClosedBeforeHead(Option), + /// `H3Closed` arrived before the response head. + ClosedBeforeHead, /// `fin`: the transport fin was observed. Body { status: u16, @@ -106,13 +106,6 @@ enum Down { delivered: u64, fin: bool, }, - Rescued { - status: u16, - cl: Option, - delivered: u64, - body: Vec, - off: usize, - }, /// The next `read_head` / `read_body` returns `Fail`. Failed, } @@ -254,36 +247,21 @@ impl Exchanges { } /// SP4 spec §4.3: readiness goes to the owner; `H3Closed` ends the upload, - /// moves the response side (rescue or failure) and reads as `Readable`. + /// settles the response side and reads as `Readable`. /// Events for unknown ids give `None` and are left as they were; a known - /// `H3Closed`'s rescue is taken out of `ev` (no second copy of the body). + /// `H3Closed` preserves a response whose FIN was already consumed. pub fn on_event(&mut self, ev: &mut Event) -> Option<(O, H3ReqId, Ready)> { let (id, ready) = match ev { Event::H3Readable(id) => (*id, Ready::Readable), Event::H3Writable(id) => (*id, Ready::Writable), - Event::H3Closed(id, close) => { + Event::H3Closed(id, _) => { let x = self.by_id.get_mut(id)?; // The transport already removed the request slot: later ops are `Stale`. x.live = false; x.up = Up::Done; x.down = match std::mem::replace(&mut x.down, Down::Failed) { - Down::AwaitHead => Down::ClosedBeforeHead(close.unread.take()), - // `fin: true` keeps `Body`: nothing is rescued after a consumed fin. - Down::Body { - status, - cl, - delivered, - fin: false, - } => match close.unread.take() { - Some(u) => Down::Rescued { - status, - cl, - delivered, - body: u.body, - off: 0, - }, - None => Down::Failed, - }, + Down::AwaitHead => Down::ClosedBeforeHead, + Down::Body { fin: false, .. } => Down::Failed, d => d, }; (*id, Ready::Readable) @@ -306,7 +284,7 @@ impl Exchanges { } /// SP4 spec §4.3 `read_head`: collect the head under the §5 limits, from - /// the transport or from a rescue. `Blocked` / `Stale` wait (a stale + /// the transport. `Blocked` / `Stale` wait (a stale /// readiness waits for `H3Closed`, SP3-1). A `Fail` removes the exchange. /// After `Head` it is a no-op `Wait`. pub fn read_head(&mut self, cx: &mut Cx<'_>, id: H3ReqId) -> HeadOut { @@ -314,53 +292,36 @@ impl Exchanges { return HeadOut::Fail(Reject::UpstreamReset); }; let mut col = HeadCollector::default(); - // `rescue`: `Ok(body)` from a rescue, else `Err(fin)` of the section. - let (head, rescue) = match &mut x.down { + let (head, fin) = match &mut x.down { Down::AwaitHead => match cx.h3_recv_headers(id, &mut |n, v| col.push(n, v)) { - Ok(fin) => (col.finish(), Err(fin)), + Ok(fin) => (col.finish(), fin), Err(StreamError::Blocked | StreamError::Stale) => return HeadOut::Wait, Err(StreamError::Reset | StreamError::Conn) => { self.fail(cx, id); return HeadOut::Fail(Reject::UpstreamReset); } }, - Down::ClosedBeforeHead(Some(u)) if u.headers.is_some() => { - for (n, v) in u.headers.iter().flatten() { - col.push(n, v); - } - // The end comes from the rescue, through `read_body`. - (col.finish(), Ok(std::mem::take(&mut u.body))) - } - Down::ClosedBeforeHead(_) | Down::Failed => { + Down::ClosedBeforeHead | Down::Failed => { self.fail(cx, id); return HeadOut::Fail(Reject::UpstreamReset); } - Down::Body { .. } | Down::Rescued { .. } => return HeadOut::Wait, + Down::Body { .. } => return HeadOut::Wait, }; let Ok(head) = head else { self.fail(cx, id); return HeadOut::Fail(Reject::UpstreamProtocol); }; let (status, cl) = (head.status, head.cl); - x.down = match rescue { - Err(fin) => Down::Body { - status, - cl, - delivered: 0, - fin, - }, - Ok(body) => Down::Rescued { - status, - cl, - delivered: 0, - body, - off: 0, - }, + x.down = Down::Body { + status, + cl, + delivered: 0, + fin, }; HeadOut::Head(head) } - /// SP4 spec §4.3 `read_body`: the transport's bytes, or the rescue in + /// SP4 spec §4.3 `read_body`: the transport's bytes in /// `buf`-sized slices; the read that ends the body goes through the end /// rule. An empty `buf` consumes nothing; on a live body it is still one /// transport read, which xquic answers with the fin only when no body is @@ -377,7 +338,7 @@ impl Exchanges { }; // `(n, Some(short))` when this read ends the body. let (n, end) = match &mut x.down { - Down::AwaitHead | Down::ClosedBeforeHead(_) => return BodyOut::Wait, + Down::AwaitHead | Down::ClosedBeforeHead => return BodyOut::Wait, Down::Failed => { self.fail(cx, id); return BodyOut::Fail; @@ -406,23 +367,6 @@ impl Exchanges { return BodyOut::Fail; } }, - Down::Rescued { - status, - cl, - delivered, - body, - off, - } => { - let n = buf.len().min(body.len() - *off); - if n == 0 && *off < body.len() { - return BodyOut::Wait; - } - buf[..n].copy_from_slice(&body[*off..*off + n]); - *off += n; - *delivered += n as u64; - let end = *off == body.len(); - (n, end.then(|| short(*status, *cl, *delivered))) - } }; match end { None => BodyOut::Data(n), @@ -480,8 +424,6 @@ mod tests { use std::net::{Ipv4Addr, SocketAddr}; const IDS: usize = 4; - /// Every rescue carries these bytes (`Unread.body`). - const RESCUE: &[u8] = b"xyz"; #[derive(Clone, Debug)] enum Op { @@ -499,8 +441,6 @@ mod tests { Writable(usize), Closed { id: usize, - unread: bool, - headers: bool, }, /// `err` queues a `Reset` for the next `h3_send_body`. Send { @@ -543,13 +483,7 @@ mod tests { err }), id.clone().prop_map(Op::Writable), - (id.clone(), any::(), any::()).prop_map(|(id, unread, headers)| { - Op::Closed { - id, - unread, - headers, - } - }), + id.clone().prop_map(|id| Op::Closed { id }), (id.clone(), 0..4usize, any::(), rare()) .prop_map(|(id, len, fin, err)| Op::Send { id, len, fin, err }), (id.clone(), 0..6u64).prop_map(|(id, buffered)| Op::Eof { id, buffered }), @@ -582,18 +516,12 @@ mod tests { #[derive(Clone, Copy, Debug, PartialEq)] enum MDown { AwaitHead, - /// `Some(headers present)` with a rescue. - ClosedBeforeHead(Option), + ClosedBeforeHead, Body { fin: bool, cl: Option, delivered: u64, }, - Rescued { - cl: Option, - delivered: u64, - left: usize, - }, Failed, } @@ -678,7 +606,7 @@ mod tests { (BodyOut::Last(n), r) } - fn on_closed(&mut self, unread: Option) { + fn on_closed(&mut self) { self.closed = true; if self.removed { return; @@ -686,19 +614,8 @@ mod tests { self.live = false; self.up = MUp::Done; self.down = match self.down { - MDown::AwaitHead => MDown::ClosedBeforeHead(unread), - MDown::Body { - fin: false, - cl, - delivered, - } => match unread { - Some(_) => MDown::Rescued { - cl, - delivered, - left: RESCUE.len(), - }, - None => MDown::Failed, - }, + MDown::AwaitHead => MDown::ClosedBeforeHead, + MDown::Body { fin: false, .. } => MDown::Failed, d => d, }; } @@ -779,18 +696,10 @@ mod tests { } } } - MDown::ClosedBeforeHead(Some(true)) => { - self.down = MDown::Rescued { - cl: None, - delivered: 0, - left: RESCUE.len(), - }; - (MHead::Head(200, None), 0) - } - MDown::ClosedBeforeHead(_) | MDown::Failed => { + MDown::ClosedBeforeHead | MDown::Failed => { (MHead::Fail(Reject::UpstreamReset), self.fail()) } - MDown::Body { .. } | MDown::Rescued { .. } => (MHead::Wait, 0), + MDown::Body { .. } => (MHead::Wait, 0), } } @@ -800,7 +709,7 @@ mod tests { return (BodyOut::Fail, vec![], 0); } match self.down { - MDown::AwaitHead | MDown::ClosedBeforeHead(_) => (BodyOut::Wait, vec![], 0), + MDown::AwaitHead | MDown::ClosedBeforeHead => (BodyOut::Wait, vec![], 0), MDown::Failed => (BodyOut::Fail, vec![], self.fail()), MDown::Body { fin: true, @@ -842,34 +751,11 @@ mod tests { let (o, c) = self.end(n, cl, delivered); (o, bytes, 1 + c) } - MDown::Rescued { - cl, - delivered, - left, - } => { - let n = cap.min(left); - if n == 0 && left > 0 { - return (BodyOut::Wait, vec![], 0); - } - let off = RESCUE.len() - left; - let bytes = RESCUE[off..off + n].to_vec(); - let (delivered, left) = (delivered + n as u64, left - n); - self.down = MDown::Rescued { - cl, - delivered, - left, - }; - if left > 0 { - return (BodyOut::Data(n), bytes, 0); - } - let (o, c) = self.end(n, cl, delivered); - (o, bytes, c) - } } } } - fn close(unread: Option) -> H3Close { + fn close() -> H3Close { let stats = H3ReqStats { send_body: 0, recv_body: 0, @@ -881,7 +767,7 @@ mod tests { stream_err: 0, close_msg: None, }; - H3Close { stats, unread } + H3Close { stats } } fn resets(t: &ScriptedHandle, r: H3ReqId) -> usize { @@ -970,18 +856,14 @@ mod tests { prop_assert_eq!(got, want); 0 } - Op::Closed { id, unread, headers } => { + Op::Closed { id } => { let (r, s) = (ids[id], &mut m[id]); if !s.closed { - let u = unread.then(|| Unread { - headers: headers.then(|| hs(&[(":status", "200".to_owned())])), - body: RESCUE.to_vec(), - }); - t.close_h3(r, close(u.clone())); - let got = ex.on_event(&mut Event::H3Closed(r, Box::new(close(u)))); + t.close_h3(r, close()); + let got = ex.on_event(&mut Event::H3Closed(r, Box::new(close()))); let want = (!s.removed).then_some((owner(id), r, Ready::Readable)); prop_assert_eq!(got, want); - s.on_closed(unread.then_some(headers)); + s.on_closed(); } 0 } diff --git a/crates/mq-proxy/tests/client_exchange.rs b/crates/mq-proxy/tests/client_exchange.rs index f942f827..a77feb14 100644 --- a/crates/mq-proxy/tests/client_exchange.rs +++ b/crates/mq-proxy/tests/client_exchange.rs @@ -1,6 +1,6 @@ //! SP4 spec §4.2/§4.3: the H3 exchange core — open, the upload send rules, //! the upload EOF rule, reset, event routing, the pull reads, the `H3Closed` -//! rescue, the end rule and failure settlement — on a +//! close handling, the end rule and failure settlement — on a //! `Shard`. use mq_http::headers::{Reject, parse_method, parse_target}; @@ -12,9 +12,7 @@ use mq_runtime::{ AcceptMeta, App, Cx, DialError, DialOpId, ListenerTag, Shard, SocketOpId, TcpEnd, TcpId, TimerId, UdpSocketId, }; -use mq_transport_api::{ - ConnId, Error, Event, H3Close, H3ReqId, H3ReqStats, StreamError, Time, Unread, -}; +use mq_transport_api::{ConnId, Error, Event, H3Close, H3ReqId, H3ReqStats, StreamError, Time}; use std::io; use std::net::{Ipv4Addr, SocketAddr}; @@ -171,15 +169,9 @@ impl H { .with_app(self.now, |a, _| std::mem::take(&mut a.out)) } - /// `H3Closed` for `r` with `unread`; the recorded outputs. - fn closed(&mut self, r: H3ReqId, unread: Option) -> Vec<(u32, H3ReqId, Ready)> { - self.t.close_h3( - r, - H3Close { - stats: stats(), - unread, - }, - ); + /// `H3Closed` for `r`; the recorded outputs. + fn closed(&mut self, r: H3ReqId) -> Vec<(u32, H3ReqId, Ready)> { + self.t.close_h3(r, H3Close { stats: stats() }); self.drive() } @@ -225,13 +217,6 @@ fn hs(pairs: &[(&str, &str)]) -> Vec<(Vec, Vec)> { .collect() } -fn unread(headers: Option<&[(&str, &str)]>, body: &[u8]) -> Option { - Some(Unread { - headers: headers.map(hs), - body: body.to_vec(), - }) -} - const OK: &[(&str, &str)] = &[(":status", "200")]; fn stats() -> H3ReqStats { @@ -381,7 +366,7 @@ fn send_stale_is_blocked_then_done_after_h3closed() { h.t.expect_h3_send_body(r, Err(StreamError::Stale)); assert_eq!(h.send(r, b"hello", false), SendOut::Blocked); assert_eq!(h.resets(r), 0); - assert_eq!(h.closed(r, None), [(9, r, Ready::Readable)]); + assert_eq!(h.closed(r), [(9, r, Ready::Readable)]); assert_eq!(h.send(r, b"hello", false), SendOut::Done); assert!(h.contains(r)); assert_eq!(h.send_calls(r).len(), 1); @@ -468,7 +453,7 @@ fn on_event_unknown_id_none() { let never = h.t.new_h3_req_id(); assert!(h.event(Event::H3Readable(never)).is_empty()); assert!(h.event(Event::H3Writable(never)).is_empty()); - assert!(h.closed(never, None).is_empty()); + assert!(h.closed(never).is_empty()); assert!(h.event(Event::ConnEstablished(h.conn)).is_empty()); let r = h.open(BodyLen::Unknown, 3); assert_eq!(h.event(Event::H3Readable(r)), [(3, r, Ready::Readable)]); @@ -483,7 +468,7 @@ fn drain_owner_resets_only_matching() { let b = h.open(BodyLen::Unknown, 2); let c = h.open(BodyLen::Unknown, 1); // `c` is closed already: drained without a reset. - assert_eq!(h.closed(c, None), [(1, c, Ready::Readable)]); + assert_eq!(h.closed(c), [(1, c, Ready::Readable)]); h.ex(|ex, cx| ex.drain_owner(cx, |o| *o == 1)); assert_eq!((h.resets(a), h.resets(b), h.resets(c)), (1, 0, 0)); assert!(!h.contains(a) && h.contains(b) && !h.contains(c)); @@ -502,13 +487,7 @@ fn send_done_never_removes_exchange() { assert_eq!(h.send(empty, b"x", false), SendOut::Done); assert_eq!(h.send(known, b"ab", false), SendOut::Accepted(2)); assert_eq!(h.send(known, b"c", false), SendOut::Done); - h.closed( - closed, - Some(Unread { - headers: None, - body: vec![], - }), - ); + h.closed(closed); assert_eq!(h.send(closed, b"x", true), SendOut::Done); assert!(h.contains(empty) && h.contains(known) && h.contains(closed)); assert_eq!(h.len(), 3); @@ -570,15 +549,14 @@ fn head_fin_then_h3closed_none_still_last_zero() { let r = h.open(BodyLen::Empty, 7); h.respond(r, OK, true); h.head_status(r); - assert_eq!(h.closed(r, None), [(7, r, Ready::Readable)]); + assert_eq!(h.closed(r), [(7, r, Ready::Readable)]); assert_eq!(h.read_body(r, 16), (BodyOut::Last(0), vec![])); assert!(!h.contains(r)); assert_eq!(h.resets(r), 0); } /// A stale `H3Readable` (xquic signalled readability, then destroyed the -/// request: QPACK-blocked HEADERS decoded after the close timer) queued -/// ahead of `H3Closed`: the read waits, and the rescue still completes. +/// request) queued ahead of `H3Closed`: the read waits for the terminal event. #[test] fn stale_head_readiness_waits_for_h3closed() { let mut h = H::new(); @@ -587,12 +565,11 @@ fn stale_head_readiness_waits_for_h3closed() { assert_eq!(h.drive(), [(7, r, Ready::Readable)]); assert_eq!(h.read_head(r), HeadOut::Wait); assert!(h.contains(r)); - h.closed(r, unread(Some(OK), b"hello")); - assert_eq!(h.head_status(r), 200); - assert_eq!(h.read_body(r, 16), (BodyOut::Last(5), b"hello".to_vec())); + h.closed(r); + assert_eq!(h.read_head(r), HeadOut::Fail(Reject::UpstreamReset)); assert_eq!(h.resets(r), 0); - // The same in the body: the stale read waits, the rescue finishes it. + // The same in the body: the stale read waits for the terminal event. let r = h.open(BodyLen::Empty, 7); h.respond(r, OK, false); h.head_status(r); @@ -601,42 +578,18 @@ fn stale_head_readiness_waits_for_h3closed() { h.t.inject_h3_error(r, StreamError::Stale); h.drive(); assert_eq!(h.read_body(r, 16).0, BodyOut::Wait); - h.closed(r, unread(None, b"de")); - assert_eq!(h.read_body(r, 16), (BodyOut::Last(2), b"de".to_vec())); + h.closed(r); + assert_eq!(h.read_body(r, 16).0, BodyOut::Fail); assert_eq!(h.resets(r), 0); assert_eq!(h.len(), 0); } -#[test] -fn closed_before_head_with_headers_rescued_head_then_body() { - let mut h = H::new(); - let r = h.open(BodyLen::Empty, 7); - let hd = [(":status", "200"), ("content-length", "5")]; - assert_eq!( - h.closed(r, unread(Some(&hd), b"hello")), - [(7, r, Ready::Readable)] - ); - match h.read_head(r) { - HeadOut::Head(hd) => assert_eq!((hd.status, hd.cl), (200, Some(5))), - o => panic!("{o:?}"), - } - assert_eq!(h.read_body(r, 16), (BodyOut::Last(5), b"hello".to_vec())); - assert!(!h.contains(r)); - // Malformed rescued headers: `UpstreamProtocol`; the request is gone, no reset. - let r = h.open(BodyLen::Empty, 7); - h.closed(r, unread(Some(&[("x", "y")]), b"hello")); - assert_eq!(h.read_head(r), HeadOut::Fail(Reject::UpstreamProtocol)); - assert!(!h.contains(r)); - assert_eq!(h.count(|c| matches!(c, Call::H3Reset(_))), 0); - assert_eq!(h.count(|c| matches!(c, Call::H3RecvHeaders(_))), 0); -} - #[test] fn closed_before_head_without_headers_upstream_reset() { - for u in [None, unread(None, b"abc")] { + { let mut h = H::new(); let r = h.open(BodyLen::Empty, 7); - h.closed(r, u); + h.closed(r); assert_eq!(h.read_head(r), HeadOut::Fail(Reject::UpstreamReset)); assert!(!h.contains(r)); assert_eq!(h.resets(r), 0, "the H3 side is gone"); @@ -644,50 +597,14 @@ fn closed_before_head_without_headers_upstream_reset() { } #[test] -fn body_h3closed_with_unread_serves_rescue_in_slices() { - let mut h = H::new(); - let r = h.open(BodyLen::Empty, 7); - h.respond(r, OK, false); - h.head_status(r); - h.body(r, b"abc", false); - assert_eq!(h.read_body(r, 16).0, BodyOut::Data(3)); - h.closed(r, unread(None, b"0123456789")); - assert_eq!(h.read_body(r, 4), (BodyOut::Data(4), b"0123".to_vec())); - assert_eq!(h.read_body(r, 4), (BodyOut::Data(4), b"4567".to_vec())); - assert_eq!(h.read_body(r, 4), (BodyOut::Last(2), b"89".to_vec())); - assert!(!h.contains(r)); - assert_eq!(h.resets(r), 0); - - // The body check counts delivered plus rescued bytes: 10 + 50 < 100 - // fails, 10 + 90 completes. - for (rescued, last) in [(50, false), (90, true)] { - let r = h.open(BodyLen::Empty, 7); - h.respond(r, &[(":status", "200"), ("content-length", "100")], false); - h.head_status(r); - h.body(r, &[b'x'; 10], false); - assert_eq!(h.read_body(r, 16).0, BodyOut::Data(10)); - h.closed(r, unread(None, &vec![b'y'; rescued])); - let got = h.read_body(r, 1000).0; - let want = if last { - BodyOut::Last(rescued) - } else { - BodyOut::Fail - }; - assert_eq!(got, want, "rescued={rescued}"); - assert!(!h.contains(r)); - assert_eq!(h.resets(r), 0); - } -} - -#[test] -fn body_h3closed_without_unread_fail() { +fn body_h3closed_fails() { let mut h = H::new(); let r = h.open(BodyLen::Empty, 7); h.respond(r, OK, false); h.head_status(r); h.body(r, b"abc", false); assert_eq!(h.read_body(r, 16).0, BodyOut::Data(3)); - assert_eq!(h.closed(r, None), [(7, r, Ready::Readable)]); + assert_eq!(h.closed(r), [(7, r, Ready::Readable)]); assert_eq!(h.read_body(r, 16).0, BodyOut::Fail); assert!(!h.contains(r)); assert_eq!(h.resets(r), 0, "the H3 side is gone"); @@ -760,7 +677,7 @@ fn cl_shortfall_fail_resets_live_request() { let r = h.open(BodyLen::Empty, 7); h.respond(r, &[(":status", "200"), ("content-length", "100")], true); h.head_status(r); - h.closed(r, None); + h.closed(r); assert_eq!(h.read_body(r, 16).0, BodyOut::Fail); assert_eq!(h.resets(r), 0); // An unparsable or repeated content-length is not checked. @@ -812,7 +729,7 @@ fn early_response_resets_unfinished_upload() { let r = h.open(BodyLen::Known(10), 7); h.respond(r, OK, true); h.head_status(r); - h.closed(r, None); + h.closed(r); assert_eq!(h.read_body(r, 16).0, BodyOut::Last(0)); assert_eq!(h.resets(r), 0); assert_eq!(h.len(), 0); @@ -861,19 +778,6 @@ fn empty_buf_probe() { h.body(r, b"", true); assert_eq!(h.read_body(r, 0).0, BodyOut::Fail); assert!(!h.contains(r)); - // `Rescued`: `Wait` while bytes remain, `Last(0)` once empty. - let r = h.open(BodyLen::Empty, 7); - h.respond(r, OK, false); - h.head_status(r); - h.closed(r, unread(None, b"xy")); - assert_eq!(h.read_body(r, 0).0, BodyOut::Wait); - assert_eq!(h.read_body(r, 1), (BodyOut::Data(1), b"x".to_vec())); - assert_eq!(h.read_body(r, 1), (BodyOut::Last(1), b"y".to_vec())); - let r = h.open(BodyLen::Empty, 7); - h.respond(r, OK, false); - h.head_status(r); - h.closed(r, unread(None, b"")); - assert_eq!(h.read_body(r, 0).0, BodyOut::Last(0)); assert_eq!(h.len(), 0); } diff --git a/crates/mq-proxy/tests/client_gateway_ends.rs b/crates/mq-proxy/tests/client_gateway_ends.rs index fd147a27..8b9549f3 100644 --- a/crates/mq-proxy/tests/client_gateway_ends.rs +++ b/crates/mq-proxy/tests/client_gateway_ends.rs @@ -1,6 +1,6 @@ //! SP3 spec §5.5 the fetch request's ends (the finish write order with -//! rescued leftovers, `H3Closed` after `ConnClosed`, abort), §5.9 shutdown, -//! and §5.7 the metrics tick's two blocks. The stale readiness, rescue, +//! pending output, `H3Closed` after `ConnClosed`, abort), §5.9 shutdown, +//! and §5.7 the metrics tick's two blocks. The stale readiness, //! body check and early-response rules are the core's, tested in //! `client_exchange.rs` (SP4 spec §4.3). @@ -11,7 +11,7 @@ use mq_proxy::config::ClientConfig; use mq_runtime::testing::{Call, log_capture}; use mq_runtime::{IoRequest, IoResult, TcpId}; use mq_transport_api::{ - CloseReason, ConnId, ConnStats, ErrType, Event, H3Close, H3ReqId, H3ReqStats, PathStats, Unread, + CloseReason, ConnId, ConnStats, ErrType, Event, H3Close, H3ReqId, H3ReqStats, PathStats, }; use std::io; use std::time::Duration; @@ -59,8 +59,8 @@ fn body(h: &mut H, r: H3ReqId, bytes: &[u8], fin: bool) { h.drive(); } -/// `H3Closed` for `r` with `unread`, then let the client run. -fn closed(h: &mut H, r: H3ReqId, unread: Option) { +/// `H3Closed` for `r`, then let the client run. +fn closed(h: &mut H, r: H3ReqId) { let stats = H3ReqStats { send_body: 0, recv_body: 0, @@ -72,17 +72,10 @@ fn closed(h: &mut H, r: H3ReqId, unread: Option) { stream_err: 0, close_msg: None, }; - h.t.close_h3(r, H3Close { stats, unread }); + h.t.close_h3(r, H3Close { stats }); h.drive(); } -fn unread(headers: Option<&[(&str, &str)]>, body: &[u8]) -> Option { - Some(Unread { - headers: headers.map(hs), - body: body.to_vec(), - }) -} - fn resets(h: &H, r: H3ReqId) -> usize { h.count(|c| *c == Call::H3Reset(r)) } @@ -119,17 +112,15 @@ const UPSTREAM_RESET: &[u8] = b"HTTP/1.1 502 \r\nX-Mq-Error: upstream-reset\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; #[test] -fn finish_write_order_pending_src_terminator_close() { +fn finish_write_order_pending_terminator_close() { // Review Focus 2: a consumer > 3 PTO behind a completed download. let (mut h, tcp, r) = open(); respond(&mut h, r, &[(":status", "200")], false); let first = pattern(4 * 16_384, 0); - body(&mut h, r, &first, false); // the fourth frame is stuck (`SendBufFull`) - let rescued = pattern(100 * 1024, 7); - closed(&mut h, r, unread(None, &rescued)); + body(&mut h, r, &first, true); // the fourth frame is stuck (`SendBufFull`) + closed(&mut h, r); let mut want = HEAD_CHUNKED.to_vec(); want.extend(chunked(&first)); - want.extend(chunked(&rescued)); want.extend_from_slice(b"0\r\n\r\n"); let mut out = Vec::new(); for _ in 0..16 { @@ -141,21 +132,7 @@ fn finish_write_order_pending_src_terminator_close() { } } assert_eq!(out.len(), want.len()); - assert!(out == want, "pending, then rescued frames, then terminator"); - assert!(!owned(&h, tcp)); - assert_eq!(resets(&h, r), 0); -} - -/// A rescue that arrives before the head: the core serves head and body, -/// the front renders head, chunked body and terminator. -#[test] -fn finish_from_unread_src() { - let (mut h, tcp, r) = open(); - closed(&mut h, r, unread(Some(&[(":status", "200")]), b"hello")); - let mut want = HEAD_CHUNKED.to_vec(); - want.extend_from_slice(b"5\r\nhello\r\n0\r\n\r\n"); - assert_eq!(h.tx_all(tcp), want); - assert_eq!(close_of(&mut h, tcp), Some(false)); + assert!(out == want, "pending, then terminator"); assert!(!owned(&h, tcp)); assert_eq!(resets(&h, r), 0); } @@ -213,7 +190,7 @@ fn h3closed_after_tunnel_closed_still_ends_request() { }, )); assert!(owned(&h, tcp), "ConnClosed alone does not end it"); - closed(&mut h, r, None); + closed(&mut h, r); if started { assert_eq!(close_of(&mut h, tcp), Some(true)); } else { @@ -255,7 +232,7 @@ fn shutdown_aborts_live_fetch_requests() { assert!(!owned(&h, t)); } assert_eq!(resets(&h, r), 1); - closed(&mut h, r, None); // the reset's own close: ignored + closed(&mut h, r); // the reset's own close: ignored assert_eq!(resets(&h, r), 1); } diff --git a/crates/mq-proxy/tests/client_gateway_upload.rs b/crates/mq-proxy/tests/client_gateway_upload.rs index f259e06c..37723e4a 100644 --- a/crates/mq-proxy/tests/client_gateway_upload.rs +++ b/crates/mq-proxy/tests/client_gateway_upload.rs @@ -8,7 +8,7 @@ use common::*; use mq_proxy::config::ClientConfig; use mq_runtime::testing::Call; use mq_runtime::{IoRequest, IoResult, TcpId}; -use mq_transport_api::{ConnId, Event, H3Close, H3ReqId, H3ReqStats, StreamError, Unread}; +use mq_transport_api::{ConnId, Event, H3Close, H3ReqId, H3ReqStats, StreamError}; use std::io; /// A gateway-only client whose tunnel is established. @@ -172,9 +172,9 @@ fn send_body_conn_error_aborts() { } /// SP4 spec §4.3 / §13.20: a `Stale` send is `Blocked` — the request's -/// `H3Closed` is queued; its rescue completes the request (SP3 aborted). +/// `H3Closed` is queued; it settles the failed response. #[test] -fn send_stale_waits_for_h3closed_then_rescues() { +fn send_stale_waits_for_h3closed_then_fails() { let (mut h, tcp, r) = open(5, b"hello", &[Err(StreamError::Stale)]); assert_eq!(close_of(&mut h, tcp), None, "no abort"); assert_eq!(resets(&h, r), 0); @@ -189,21 +189,11 @@ fn send_stale_waits_for_h3closed_then_rescues() { stream_err: 0, close_msg: None, }; - let unread = Unread { - headers: Some(vec![(b":status".to_vec(), b"200".to_vec())]), - body: b"ok".to_vec(), - }; - h.t.close_h3( - r, - H3Close { - stats, - unread: Some(unread), - }, - ); + h.t.close_h3(r, H3Close { stats }); h.drive(); assert_eq!( h.tx_all(tcp), - b"HTTP/1.1 200 \r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nok\r\n0\r\n\r\n" + b"HTTP/1.1 502 \r\nX-Mq-Error: upstream-reset\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ); assert_eq!(close_of(&mut h, tcp), Some(false)); assert_eq!(resets(&h, r), 0); diff --git a/crates/mq-proxy/tests/client_mitm_stream.rs b/crates/mq-proxy/tests/client_mitm_stream.rs index a9da73ad..566eceda 100644 --- a/crates/mq-proxy/tests/client_mitm_stream.rs +++ b/crates/mq-proxy/tests/client_mitm_stream.rs @@ -10,7 +10,7 @@ use mitm_harness::*; use mq_proxy::client::mitm::MitmTuning; use mq_runtime::TcpId; use mq_runtime::testing::Call; -use mq_transport_api::{ConnId, Event, H3Close, H3ReqId, H3ReqStats, StreamError, Unread}; +use mq_transport_api::{ConnId, Event, H3ReqId, StreamError}; use std::time::Duration; const US: Duration = Duration::from_micros(1); @@ -32,20 +32,6 @@ fn hs(pairs: &[(&str, &str)]) -> Vec<(Vec, Vec)> { .collect() } -fn stats() -> H3ReqStats { - H3ReqStats { - send_body: 0, - recv_body: 0, - begin_us: 0, - header_send_us: 0, - fin_send_us: 0, - fin_ack_us: 0, - mp_state: 0, - stream_err: 0, - close_msg: None, - } -} - /// A MITM conn with a live tunnel conn and a handshaken browser. struct T { mh: MH, @@ -431,7 +417,7 @@ fn empty_end_stream_with_zero_capacity_completes() { /// R1: a separate empty H3 FIN after a bodyless head is seen by the terminal /// probe, so its END_STREAM needs no h2 credit, even with the upload open -/// (no `H3Closed` to rescue it). +/// before `H3Closed` arrives. #[test] fn separate_empty_fin_with_zero_capacity_and_open_upload_completes() { let mut t = T::new(); @@ -476,33 +462,6 @@ fn buffered_data_capacity_grant_without_new_h3_event_delivers() { assert!(t.b.response(&s).expect("complete").1 == body); } -#[test] -fn rescued_body_capacity_grant_without_new_h3_event_delivers() { - let mut t = T::new(); - t.b.hold_capacity(true); - let (s, r) = t.req("GET", "/x", &[], b""); - t.head(r, "200", &[], false); - let body = pattern(WIN + 1000); - t.body(r, &body[..WIN], false); - assert_eq!(t.b.received(&s).len(), WIN); - let unread = Unread { - headers: None, - body: body[WIN..].to_vec(), - }; - let close = H3Close { - stats: stats(), - unread: Some(unread), - }; - t.mh.t.close_h3(r, close); - t.flow(); - assert_eq!(t.b.received(&s).len(), WIN, "no capacity yet"); - t.b.hold_capacity(false); - t.b.release(&s); - t.flow(); - assert!(t.b.response(&s).expect("complete").1 == body); - assert_eq!(t.streams(), 0); -} - #[test] fn idle_sse_quiescent_no_continuation() { let mut t = T::new(); diff --git a/crates/mq-proxy/tests/server_gateway_metrics.rs b/crates/mq-proxy/tests/server_gateway_metrics.rs index 00a6894a..d5a49fe9 100644 --- a/crates/mq-proxy/tests/server_gateway_metrics.rs +++ b/crates/mq-proxy/tests/server_gateway_metrics.rs @@ -126,13 +126,7 @@ fn stats0() -> H3ReqStats { /// `H3Closed` with `stats`; returns every log line it produced. fn close_log(h: &mut H, r: H3ReqId, stats: H3ReqStats) -> Vec { log_capture::take(); - h.t.close_h3( - r, - H3Close { - stats, - unread: None, - }, - ); + h.t.close_h3(r, H3Close { stats }); h.drive(); log_capture::take() } diff --git a/crates/mq-proxy/tests/server_gateway_relay.rs b/crates/mq-proxy/tests/server_gateway_relay.rs index 168ff071..64f9d342 100644 --- a/crates/mq-proxy/tests/server_gateway_relay.rs +++ b/crates/mq-proxy/tests/server_gateway_relay.rs @@ -120,13 +120,7 @@ fn closed(h: &mut H, r: H3ReqId) { stream_err: 0, close_msg: None, }; - h.t.close_h3( - r, - H3Close { - stats, - unread: None, - }, - ); + h.t.close_h3(r, H3Close { stats }); h.drive(); } diff --git a/crates/mq-runtime/tests/app.rs b/crates/mq-runtime/tests/app.rs index fc251083..4a142314 100644 --- a/crates/mq-runtime/tests/app.rs +++ b/crates/mq-runtime/tests/app.rs @@ -5,9 +5,8 @@ use mq_runtime::{ StreamPreread, Target, TcpEnd, TcpId, TimerId, UdpSocketId, }; use mq_transport_api::{ - ConnConfig, ConnId, ConnStats, ConnectError, DatagramError, Error, Event, H3Header, H3ReqId, - H3ReqInfo, PathError, PathId, SlotId, StreamError, StreamId, StreamInfo, Time, Transmit, - TransportOps, TxKey, + ConnConfig, ConnId, ConnStats, ConnectError, DatagramError, Error, Event, PathError, PathId, + SlotId, StreamError, StreamId, StreamInfo, Time, Transmit, TransportOps, TxKey, }; use std::io; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; @@ -85,50 +84,6 @@ impl TransportOps for Null { fn open_uni(&mut self, _: Time, _: ConnId) -> Result { Err(Error::Role) } - fn open_h3_request(&mut self, _: Time, _: ConnId) -> Result { - Err(Error::Role) - } - fn h3_send_headers( - &mut self, - _: Time, - _: H3ReqId, - _: &[H3Header<'_>], - _: bool, - ) -> Result<(), StreamError> { - Err(StreamError::Stale) - } - fn h3_send_body( - &mut self, - _: Time, - _: H3ReqId, - _: &[u8], - _: bool, - ) -> Result { - Err(StreamError::Stale) - } - fn h3_finish(&mut self, _: Time, _: H3ReqId) -> Result<(), StreamError> { - Err(StreamError::Stale) - } - fn h3_recv_headers( - &mut self, - _: Time, - _: H3ReqId, - _: &mut dyn FnMut(&[u8], &[u8]), - ) -> Result { - Err(StreamError::Stale) - } - fn h3_recv_body( - &mut self, - _: Time, - _: H3ReqId, - _: &mut [u8], - ) -> Result<(usize, bool), StreamError> { - Err(StreamError::Stale) - } - fn h3_reset(&mut self, _: Time, _: H3ReqId) {} - fn h3_req_info(&self, _: H3ReqId) -> Result { - Err(Error::Stale) - } } /// Toy app implementing every §5.4 callback. diff --git a/crates/mq-runtime/tests/scripted.rs b/crates/mq-runtime/tests/scripted.rs index 7c2b54da..19c443a8 100644 --- a/crates/mq-runtime/tests/scripted.rs +++ b/crates/mq-runtime/tests/scripted.rs @@ -3,7 +3,7 @@ use mq_runtime::testing::{Call, ScriptedTransport}; use mq_transport_api::{ ConnConfig, ConnProto, DatagramError, Error, Event, H3Close, H3Header, H3ReqStats, PathId, - StreamError, Time, TransportOps, Unread, + StreamError, Time, TransportOps, }; use std::net::{Ipv4Addr, SocketAddr}; use std::time::Duration; @@ -435,17 +435,11 @@ fn scripted_h3_recv_error_injected() { } #[test] -fn scripted_h3_close_carries_unread() { +fn scripted_h3_close_carries_stats() { let (mut t, h) = ScriptedTransport::new(); let c = h.new_conn_id(); let r = h.new_h3_request(c); - let close = H3Close { - stats: stats(), - unread: Some(Unread { - headers: Some(pairs(&[("k", "v")])), - body: b"tail".to_vec(), - }), - }; + let close = H3Close { stats: stats() }; h.close_h3(r, close.clone()); let _ = t.poll_event(); // H3Request assert_eq!(t.poll_event(), Some(Event::H3Closed(r, Box::new(close)))); @@ -456,13 +450,7 @@ fn scripted_h3_stale_after_close() { let (mut t, h) = ScriptedTransport::new(); let c = h.new_conn_id(); let r = h.new_h3_request(c); - h.close_h3( - r, - H3Close { - stats: stats(), - unread: None, - }, - ); + h.close_h3(r, H3Close { stats: stats() }); let mut buf = [0u8; 8]; assert_eq!(t.h3_send_headers(T, r, &[], false), Err(StreamError::Stale)); assert_eq!(t.h3_send_body(T, r, b"x", false), Err(StreamError::Stale)); diff --git a/crates/mq-transport-api/src/config.rs b/crates/mq-transport-api/src/config.rs index 268735c0..ece1731e 100644 --- a/crates/mq-transport-api/src/config.rs +++ b/crates/mq-transport-api/src/config.rs @@ -16,23 +16,13 @@ pub struct TransportConfig { pub cc: CongestionControl, /// Wall clock minus monotonic, fixed at creation (spec §4.3). pub realtime_offset_us: i64, - /// Register the H3 context (spec §3.1). + /// Register ALPN `h3` on raw stream callbacks (adoption spec §3). pub h3: bool, - /// Which stack serves ALPN `h3`; read only when `h3` is true (adoption spec §3). - pub h3_backend: H3Backend, /// Write `/{client,server}.qlog`, opened (truncated) at creation. xquic formats qlog /// events only when this is set (spec §4.9). pub qlog: Option, } -/// Which stack serves ALPN `h3` (adoption spec §3); read only when `h3` is true. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] -pub enum H3Backend { - #[default] - XqcH3, - Raw, -} - /// Client or server; the server needs a cert and key (spec §4.2). #[derive(Clone, Debug, PartialEq, Eq)] pub enum Role { diff --git a/crates/mq-transport-api/src/event.rs b/crates/mq-transport-api/src/event.rs index 0c071221..c77c5138 100644 --- a/crates/mq-transport-api/src/event.rs +++ b/crates/mq-transport-api/src/event.rs @@ -25,8 +25,7 @@ pub enum Event { StreamPeerReset(StreamId, u64), /// The peer's STOP_SENDING code; once, raw-H3 conns only (adoption spec §3). StreamStopSending(StreamId, u64), - /// H3-proto conns only (`ConnProto::H3`; equivalent to raw-H3 today, since xqc_h3-backend - /// conns never reach the raw stream-close path): directly before that stream's + /// H3-proto conns only (`ConnProto::H3`): directly before that stream's /// `StreamClosed`, never dropped (adoption spec §3). StreamCloseStats(StreamId, Box), /// "A path can be created now"; may repeat. @@ -84,17 +83,6 @@ pub struct StreamCloseStats { #[derive(Clone, Debug, PartialEq, Eq)] pub struct H3Close { pub stats: H3ReqStats, - /// Present iff the app had not yet consumed the request's fin AND the drain inside - /// the close notification ended with xquic's `fin` set (a complete body); a partial - /// body (idle timeout, CONNECTION_CLOSE, reset, local close) is never rescued (§3.7). - pub unread: Option, -} - -/// What the close-time drain read; the fin is implied (spec §3.1). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Unread { - pub headers: Option, Vec)>>, - pub body: Vec, } /// Facade request metadata (spec §3.1). diff --git a/crates/mq-transport-api/src/lib.rs b/crates/mq-transport-api/src/lib.rs index 74ae9c41..056efb2b 100644 --- a/crates/mq-transport-api/src/lib.rs +++ b/crates/mq-transport-api/src/lib.rs @@ -10,13 +10,13 @@ pub mod ringbuf; mod time; pub use config::{ - CongestionControl, ConnConfig, ConnProto, ConnStats, H3Backend, PathStats, Role, Scheduler, + CongestionControl, ConnConfig, ConnProto, ConnStats, PathStats, Role, Scheduler, TransportConfig, }; pub use error::{ConnectError, DatagramError, Error, PathError, StreamError}; pub use event::{ CloseReason, ErrType, Event, H3Close, H3Header, H3ReqInfo, H3ReqStats, StreamCloseStats, - StreamInfo, StreamKind, Transmit, Unread, + StreamInfo, StreamKind, Transmit, }; pub use ids::{ConnId, H3ReqId, PathId, SlotId, StreamId, TxKey}; pub use ops::TransportOps; diff --git a/crates/mq-transport-api/src/ops.rs b/crates/mq-transport-api/src/ops.rs index 905fc111..63c599d8 100644 --- a/crates/mq-transport-api/src/ops.rs +++ b/crates/mq-transport-api/src/ops.rs @@ -51,7 +51,7 @@ pub trait TransportOps { ) -> Result<(usize, bool), StreamError>; fn stream_reset(&mut self, now: Time, s: StreamId); /// A local unidirectional stream, either role (adoption spec §3). `Err(Stale)` for a dead - /// conn, `Err(Other)` on an xqc_h3 conn or when the peer's uni credit is exhausted, + /// conn, `Err(Other)` when the peer's uni credit is exhausted, /// `Err(Ceiling)` at 8192 streams. fn open_uni(&mut self, now: Time, conn: ConnId) -> Result; /// RESET_STREAM only; the receive side keeps reporting. Stale id: no-op (adoption spec §3). @@ -82,40 +82,167 @@ pub trait TransportOps { // H3 requests (spec §3.1) /// Client only: a server-role transport returns `Error::Role`. - fn open_h3_request(&mut self, now: Time, conn: ConnId) -> Result; + fn open_h3_request(&mut self, _: Time, _: ConnId) -> Result { + Err(Error::Role) + } /// All-or-error; `Blocked` cannot occur with the vendored xquic (spec §3.1). fn h3_send_headers( &mut self, - now: Time, - r: H3ReqId, - hs: &[H3Header<'_>], - fin: bool, - ) -> Result<(), StreamError>; + _: Time, + _: H3ReqId, + _: &[H3Header<'_>], + _: bool, + ) -> Result<(), StreamError> { + Err(StreamError::Conn) + } /// The `stream_send` contract: `Ok(n)` accepted, FIN only when `n == data.len()`. fn h3_send_body( &mut self, - now: Time, - r: H3ReqId, - data: &[u8], - fin: bool, - ) -> Result; + _: Time, + _: H3ReqId, + _: &[u8], + _: bool, + ) -> Result { + Err(StreamError::Conn) + } /// A bare FIN (spec §3.1). - fn h3_finish(&mut self, now: Time, r: H3ReqId) -> Result<(), StreamError>; + fn h3_finish(&mut self, _: Time, _: H3ReqId) -> Result<(), StreamError> { + Err(StreamError::Conn) + } /// Drains one header section; `Ok(fin)`. `Blocked` when none is pending. fn h3_recv_headers( &mut self, - now: Time, - r: H3ReqId, - each: &mut dyn FnMut(&[u8], &[u8]), - ) -> Result; + _: Time, + _: H3ReqId, + _: &mut dyn FnMut(&[u8], &[u8]), + ) -> Result { + Err(StreamError::Conn) + } /// `(bytes, fin)`; `(0, false)` is `Blocked`. fn h3_recv_body( &mut self, - now: Time, - r: H3ReqId, - buf: &mut [u8], - ) -> Result<(usize, bool), StreamError>; + _: Time, + _: H3ReqId, + _: &mut [u8], + ) -> Result<(usize, bool), StreamError> { + Err(StreamError::Conn) + } /// A no-op on a stale id. - fn h3_reset(&mut self, now: Time, r: H3ReqId); - fn h3_req_info(&self, r: H3ReqId) -> Result; + fn h3_reset(&mut self, _: Time, _: H3ReqId) {} + fn h3_req_info(&self, _: H3ReqId) -> Result { + Err(Error::Role) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + struct RawOnly; + + impl TransportOps for RawOnly { + fn recv_datagram(&mut self, _: Time, _: SocketAddr, _: SocketAddr, _: &[u8]) {} + fn drive(&mut self, _: Time) {} + fn pending_transmit(&self, _: &mut Vec) {} + fn peek_transmit(&mut self, _: TxKey) -> Option> { + None + } + fn transmit_done(&mut self, _: TxKey, _: usize) {} + fn resume_pending(&self) -> bool { + false + } + fn poll_event(&mut self) -> Option { + None + } + fn next_timeout(&self) -> Option