From 78ccde63ea44f56b764d6ec1006c9a43c8336069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Mon, 13 Jul 2026 22:26:49 +0200 Subject: [PATCH 1/2] fix(proto): Avoid double-emitting `Draining` event, causing an active_connections underflow --- noq-proto/src/connection/mod.rs | 2 +- noq-proto/src/tests/mod.rs | 71 +++++++++++++++++++++++++++++++++ noq-proto/src/tests/util.rs | 4 +- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/noq-proto/src/connection/mod.rs b/noq-proto/src/connection/mod.rs index f0082b819..3275d18f1 100644 --- a/noq-proto/src/connection/mod.rs +++ b/noq-proto/src/connection/mod.rs @@ -4408,7 +4408,7 @@ impl Connection { .. }) => { let was_draining = self.state.move_to_drained(Some(conn_err)); - if !was_draining { + if !was_draining && !was_drained { self.endpoint_events.push_back(EndpointEventInner::Draining); } } diff --git a/noq-proto/src/tests/mod.rs b/noq-proto/src/tests/mod.rs index de659a9e5..9f1255ce9 100644 --- a/noq-proto/src/tests/mod.rs +++ b/noq-proto/src/tests/mod.rs @@ -309,6 +309,77 @@ fn stateless_reset_limit() { assert!(matches!(event, Some(DatagramEvent::Response(_)))); } +/// Regression test to ensure a connection that is already `Drained` doesn't emit a +/// duplicate `Draining` endpoint event when a second stateless-reset datagram is processed. +#[test] +fn duplicate_stateless_reset_emits_single_draining() { + let _guard = subscribe(); + let mut key_material = vec![0; 64]; + let mut rng = rand::rng(); + rng.fill_bytes(&mut key_material); + let reset_key = hmac::Key::new(hmac::HMAC_SHA256, &key_material); + rng.fill_bytes(&mut key_material); + + let mut endpoint_config = EndpointConfig::new(Arc::new(reset_key)); + endpoint_config.cid_generator(Arc::new(move || { + Box::new(HashedConnectionIdGenerator::from_key(0)) + })); + let endpoint_config = Arc::new(endpoint_config); + + let mut pair = Pair::new(endpoint_config.clone(), server_config()); + let (client_ch, _) = pair.connect(); + pair.drive(); // Flush any post-handshake frames + + // Recreate the server endpoint so it loses all connection state but keeps the same + // reset key, causing it to respond to the client's packets with stateless resets. + pair.server.endpoint = Endpoint::new(endpoint_config, Some(Arc::new(server_config())), true); + // Force the server to generate the smallest possible stateless reset + pair.client.connections.get_mut(&client_ch).unwrap().ping(); + pair.drive_client(); + pair.drive_server(); + + // Capture the stateless reset datagram delivered to the client before it is processed. + let (_, captured_stateless_reset) = pair + .client + .inbound + .pop_first() + .expect("server should have sent a stateless reset"); + pair.client.inbound.clear(); + + let now = pair.time; + + // Duplicate the captured stateless reset token: + pair.client + .inbound + .push(now, captured_stateless_reset.clone()); + pair.client.inbound.push(now, captured_stateless_reset); + + // Only call drive_incoming instead of drive_client so we can manually count + // endpoint events below. + pair.client.drive_incoming(now); + + // Apply the connection events produced by the endpoint and collect all endpoint events + // emitted by the connection. + let conn = pair.client.connections.get_mut(&client_ch).unwrap(); + for (_, mut events) in pair.client.conn_events.drain() { + for event in events.drain(..) { + conn.handle_event(event); + } + } + + // A drained connection receiving a second stateless reset must not emit a duplicate + // `Draining` event. + let mut draining = 0; + let mut drained = 0; + while let Some(event) = conn.poll_endpoint_events() { + draining += event.is_draining() as usize; + drained += event.is_drained() as usize; + } + + assert_eq!(draining, 1, "expected exactly one Draining event"); + assert_eq!(drained, 1, "expected exactly one Drained event"); +} + #[test] fn export_keying_material() { let _guard = subscribe(); diff --git a/noq-proto/src/tests/util.rs b/noq-proto/src/tests/util.rs index 1290e0f86..c39ef1221 100644 --- a/noq-proto/src/tests/util.rs +++ b/noq-proto/src/tests/util.rs @@ -1097,7 +1097,7 @@ pub(super) struct TestEndpoint { pub(super) accepted: Option>, pub(super) connections: HashMap, pub(super) draining_connections: HashSet, - conn_events: HashMap>, + pub(super) conn_events: HashMap>, pub(super) captured_packets: Vec>, pub(super) capture_inbound_packets: bool, #[debug("handle_incoming")] @@ -1105,7 +1105,7 @@ pub(super) struct TestEndpoint { pub(super) waiting_incoming: Vec, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(super) struct Inbound { pub(super) ecn: Option, pub(super) packet: BytesMut, From fcd420c02e03cd0bc620f4345a590f4a8b8ae88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 16 Jul 2026 14:02:23 +0200 Subject: [PATCH 2/2] Allow `semicolon_in_expressions_from_macros` --- noq-udp/build.rs | 4 ++++ noq/build.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/noq-udp/build.rs b/noq-udp/build.rs index 599dd91f6..b75b2d52b 100644 --- a/noq-udp/build.rs +++ b/noq-udp/build.rs @@ -1,5 +1,9 @@ use cfg_aliases::cfg_aliases; +#[allow( + semicolon_in_expressions_from_macros, + reason = "cfg_aliases needs an update: https://github.com/katharostech/cfg_aliases/pull/15" +)] fn main() { // Setup cfg aliases cfg_aliases! { diff --git a/noq/build.rs b/noq/build.rs index 7aae56820..2adca7148 100644 --- a/noq/build.rs +++ b/noq/build.rs @@ -1,5 +1,9 @@ use cfg_aliases::cfg_aliases; +#[allow( + semicolon_in_expressions_from_macros, + reason = "cfg_aliases needs an update: https://github.com/katharostech/cfg_aliases/pull/15" +)] fn main() { // Setup cfg aliases cfg_aliases! {