5 5 |
|
6 6 | //! HTTP/2 connection behavior contracts.
|
7 7 | //!
|
8 8 | //! Each contract is implementation-neutral and has an explicit runner for every
|
9 9 | //! HTTP client backend expected to preserve that behavior.
|
10 10 |
|
11 11 | #![cfg(all(feature = "wire-mock", feature = "rustls-aws-lc"))]
|
12 12 |
|
13 13 | mod common;
|
14 14 |
|
15 + | use aws_smithy_http_client::pool::{
|
16 + | Client as PoolClient, ConnectionPool, ConnectionReuseScope, Partition, PartitionId,
|
17 + | TokioDriverSpawner,
|
18 + | };
|
15 19 | use aws_smithy_http_client::test_util::wire::connection::{ConnectionCloseReason, ManualGate};
|
16 20 | use aws_smithy_http_client::tls;
|
17 21 | use aws_smithy_http_client::Builder;
|
18 22 | use aws_smithy_runtime_api::client::connection::{
|
19 23 | CaptureSmithyConnection, ConnectionMetadata as SmithyConnectionMetadata,
|
20 24 | };
|
21 25 | use aws_smithy_runtime_api::client::http::{SharedHttpClient, SharedHttpConnector};
|
22 26 | use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
23 27 | use bytes::Bytes;
|
24 28 | use common::client as test_client;
|
25 29 | use common::client::{BackendConfig, HyperUtilLegacyPool};
|
26 30 | use common::h2::{
|
27 31 | H2BodyPlan, H2ConnectionId, H2ConnectionPlan, H2ConnectionScript, H2Event, H2Response,
|
28 32 | H2StreamScript, H2TestServer,
|
29 33 | };
|
30 34 | use common::tls as test_tls;
|
31 35 | use h2::Reason;
|
32 36 | use http_body_util::BodyExt;
|
33 37 | use std::error::Error;
|
34 38 |
|
35 39 | trait HttpsClientBackend {
|
36 40 | fn build_https(
|
37 41 | &self,
|
38 42 | config: BackendConfig,
|
39 43 | provider: tls::Provider,
|
40 44 | tls_context: tls::TlsContext,
|
41 45 | ) -> SharedHttpClient;
|
42 46 | }
|
43 47 |
|
44 48 | impl HttpsClientBackend for HyperUtilLegacyPool {
|
45 49 | fn build_https(
|
46 50 | &self,
|
47 51 | config: BackendConfig,
|
48 52 | provider: tls::Provider,
|
49 53 | tls_context: tls::TlsContext,
|
50 54 | ) -> SharedHttpClient {
|
51 55 | let mut builder = Builder::new();
|
52 56 | if let Some(pool_idle_timeout) = config.pool_idle_timeout {
|
53 57 | builder = builder.pool_idle_timeout(pool_idle_timeout);
|
54 58 | }
|
55 59 | builder
|
56 60 | .tls_provider(provider)
|
57 61 | .tls_context(tls_context)
|
58 62 | .build_https()
|
59 63 | }
|
60 64 | }
|
61 65 |
|
66 + | #[derive(Clone, Copy, Debug)]
|
67 + | struct PartitionedConnectionPool;
|
68 + |
|
69 + | impl HttpsClientBackend for PartitionedConnectionPool {
|
70 + | fn build_https(
|
71 + | &self,
|
72 + | config: BackendConfig,
|
73 + | provider: tls::Provider,
|
74 + | tls_context: tls::TlsContext,
|
75 + | ) -> SharedHttpClient {
|
76 + | let mut builder = ConnectionPool::builder().tls_provider(provider);
|
77 + | if let Some(pool_idle_timeout) = config.pool_idle_timeout {
|
78 + | builder = builder.idle_timeout(pool_idle_timeout);
|
79 + | }
|
80 + | let pool = builder
|
81 + | .tls_context(tls_context)
|
82 + | .build_https()
|
83 + | .expect("valid connection-pool config");
|
84 + | let client = PoolClient::new(&pool).expect("anonymous partition exists");
|
85 + | SharedHttpClient::new(client)
|
86 + | }
|
87 + | }
|
88 + |
|
62 89 | fn rustls_aws_lc() -> tls::Provider {
|
63 90 | tls::Provider::Rustls(tls::rustls_provider::CryptoMode::AwsLc)
|
64 91 | }
|
65 92 |
|
66 93 | fn h2_client_with_provider(
|
67 94 | backend: &dyn HttpsClientBackend,
|
68 95 | provider: tls::Provider,
|
69 96 | ) -> SharedHttpClient {
|
70 97 | backend.build_https(
|
71 98 | BackendConfig::default(),
|
150 177 | drop(connector);
|
151 178 | drop(client);
|
152 179 | server.shutdown().await.expect("clean H2 server shutdown");
|
153 180 | }
|
154 181 |
|
155 182 | #[tokio::test]
|
156 183 | async fn test_sequential_requests_reuse_connection_with_hyper_util_legacy_pool() {
|
157 184 | sequential_requests_reuse_connection(&HyperUtilLegacyPool).await;
|
158 185 | }
|
159 186 |
|
187 + | #[tokio::test]
|
188 + | async fn test_sequential_requests_reuse_connection_with_partitioned_pool() {
|
189 + | sequential_requests_reuse_connection(&PartitionedConnectionPool).await;
|
190 + | }
|
191 + |
|
160 192 | /// Concurrent requests multiplex as independent streams on a warmed H2 connection.
|
161 193 | async fn concurrent_requests_multiplex_on_warmed_connection(backend: &dyn HttpsClientBackend) {
|
162 194 | let body_gate = ManualGate::new();
|
163 195 | let script = H2ConnectionScript::new()
|
164 196 | .route("/warm", H2StreamScript::respond(H2Response::ok("warm")))
|
165 197 | .route(
|
166 198 | "/concurrent",
|
167 199 | H2StreamScript::respond(H2Response::new(http_1x::StatusCode::OK).body(
|
168 200 | H2BodyPlan::gated(Bytes::new(), body_gate.waiter(), "multiplexed"),
|
169 201 | )),
|
170 202 | );
|
171 203 | let server = H2TestServer::builder()
|
172 204 | .connections(H2ConnectionPlan::queue([script]))
|
173 205 | .start()
|
174 206 | .await
|
175 207 | .expect("H2 server should start");
|
176 208 | let client = h2_client(backend);
|
177 209 | let connector = test_client::connector(&client);
|
178 210 |
|
179 211 | let (status, body) = test_client::get_and_collect(&connector, &server.url("/warm")).await;
|
180 212 | assert_eq!((status, body.as_slice()), (200, b"warm".as_slice()));
|
181 213 |
|
182 214 | let mut requests = tokio::task::JoinSet::new();
|
183 215 | for _ in 0..4 {
|
184 216 | let connector = connector.clone();
|
185 217 | let url = server.url("/concurrent");
|
186 218 | requests.spawn(async move { test_client::get_and_collect(&connector, &url).await });
|
187 219 | }
|
188 220 |
|
189 221 | body_gate
|
190 222 | .wait_for_arrivals(4, test_client::WAIT)
|
191 223 | .await
|
192 224 | .expect("all concurrent H2 streams should reach their body gate");
|
193 225 | assert_eq!(server.connection_count(), 1);
|
194 226 | assert_eq!(server.stream_count(), 5);
|
195 227 | body_gate.release();
|
196 228 |
|
197 229 | while let Some(result) = requests.join_next().await {
|
198 230 | let (status, body) = result.expect("request task should not panic");
|
199 231 | assert_eq!((status, body.as_slice()), (200, b"multiplexed".as_slice()));
|
200 232 | }
|
201 233 |
|
202 234 | drop(connector);
|
203 235 | drop(client);
|
204 236 | server.shutdown().await.expect("clean H2 server shutdown");
|
205 237 | }
|
206 238 |
|
207 239 | #[tokio::test]
|
208 240 | async fn test_concurrent_requests_multiplex_on_warmed_connection_with_hyper_util_legacy_pool() {
|
209 241 | concurrent_requests_multiplex_on_warmed_connection(&HyperUtilLegacyPool).await;
|
210 242 | }
|
211 243 |
|
244 + | #[tokio::test]
|
245 + | async fn test_concurrent_requests_multiplex_on_warmed_connection_with_partitioned_pool() {
|
246 + | concurrent_requests_multiplex_on_warmed_connection(&PartitionedConnectionPool).await;
|
247 + | }
|
248 + |
|
212 249 | /// Concurrent cold-start requests converge on one established H2 connection even when
|
213 250 | /// connection attempts race.
|
214 251 | async fn concurrent_cold_start_converges_on_one_h2_connection(
|
215 252 | backend: &dyn HttpsClientBackend,
|
216 253 | ) {
|
217 254 | let body_gate = ManualGate::new();
|
218 255 | let script = H2ConnectionScript::new()
|
219 256 | .allow_handshake_abandonment()
|
220 257 | .fallback(H2StreamScript::respond(
|
221 258 | H2Response::new(http_1x::StatusCode::OK).body(H2BodyPlan::gated(
|
271 308 | drop(connector);
|
272 309 | drop(client);
|
273 310 | server.shutdown().await.expect("clean H2 server shutdown");
|
274 311 | }
|
275 312 |
|
276 313 | #[tokio::test]
|
277 314 | async fn test_concurrent_cold_start_converges_on_one_h2_connection_with_hyper_util_legacy_pool()
|
278 315 | {
|
279 316 | concurrent_cold_start_converges_on_one_h2_connection(&HyperUtilLegacyPool).await;
|
280 317 | }
|
318 + |
|
319 + | #[tokio::test]
|
320 + | async fn test_concurrent_cold_start_converges_on_one_h2_connection_with_partitioned_pool() {
|
321 + | concurrent_cold_start_converges_on_one_h2_connection(&PartitionedConnectionPool).await;
|
322 + | }
|
281 323 | }
|
282 324 |
|
283 325 | mod connection_metadata {
|
284 326 | use super::*;
|
285 327 |
|
286 328 | /// Poisoning captured H2 connection metadata moves later streams to a new connection.
|
287 329 | async fn poisoned_connection_is_not_reused(backend: &dyn HttpsClientBackend) {
|
288 330 | let script =
|
289 331 | H2ConnectionScript::new().fallback(H2StreamScript::respond(H2Response::ok("ok")));
|
290 332 | let server = H2TestServer::builder()
|
291 333 | .connections(H2ConnectionPlan::queue([script.clone(), script]))
|
292 334 | .start()
|
293 335 | .await
|
294 336 | .expect("H2 server should start");
|
295 337 | let client = h2_client(backend);
|
296 338 | let connector = test_client::connector(&client);
|
297 339 |
|
298 340 | let (status, body, metadata) =
|
299 341 | get_and_collect_with_capture(&connector, &server.url("/first")).await;
|
300 342 | assert_eq!((status, body.as_slice()), (200, b"ok".as_slice()));
|
301 343 | metadata.poison();
|
302 344 |
|
303 345 | let (status, body) = test_client::get_and_collect(&connector, &server.url("/second")).await;
|
304 346 | assert_eq!((status, body.as_slice()), (200, b"ok".as_slice()));
|
305 347 |
|
306 348 | let first_connection = single_stream_connection(&server, "/first");
|
307 349 | let second_connection = single_stream_connection(&server, "/second");
|
308 350 | assert_ne!(first_connection, second_connection);
|
309 351 | assert_eq!(server.connection_count(), 2);
|
310 352 |
|
311 353 | drop(metadata);
|
312 354 | drop(connector);
|
313 355 | drop(client);
|
314 356 | server.shutdown().await.expect("clean H2 server shutdown");
|
315 357 | }
|
316 358 |
|
317 359 | #[tokio::test]
|
318 360 | async fn test_poisoned_connection_is_not_reused_with_hyper_util_legacy_pool() {
|
319 361 | poisoned_connection_is_not_reused(&HyperUtilLegacyPool).await;
|
320 362 | }
|
363 + |
|
364 + | #[tokio::test]
|
365 + | async fn test_poisoned_connection_is_not_reused_with_partitioned_pool() {
|
366 + | poisoned_connection_is_not_reused(&PartitionedConnectionPool).await;
|
367 + | }
|
321 368 | }
|
322 369 |
|
323 370 | mod stream_failures {
|
324 371 | use super::*;
|
325 372 |
|
326 373 | /// A peer reset fails only its H2 stream and leaves the connection available for reuse.
|
327 374 | async fn stream_reset_does_not_retire_connection(backend: &dyn HttpsClientBackend) {
|
328 375 | let reset_gate = ManualGate::new();
|
329 376 | let script = H2ConnectionScript::new()
|
330 377 | .route(
|
576 633 | drop(client);
|
577 634 | server.shutdown().await.expect("clean H2 server shutdown");
|
578 635 | }
|
579 636 |
|
580 637 | #[tokio::test]
|
581 638 | async fn test_graceful_goaway_preserves_in_flight_stream_and_replaces_connection_with_hyper_util_legacy_pool(
|
582 639 | ) {
|
583 640 | graceful_goaway_preserves_in_flight_stream_and_replaces_connection(&HyperUtilLegacyPool)
|
584 641 | .await;
|
585 642 | }
|
643 + |
|
644 + | #[tokio::test]
|
645 + | async fn test_graceful_goaway_preserves_in_flight_stream_and_replaces_connection_with_partitioned_pool(
|
646 + | ) {
|
647 + | graceful_goaway_preserves_in_flight_stream_and_replaces_connection(
|
648 + | &PartitionedConnectionPool,
|
649 + | )
|
650 + | .await;
|
651 + | }
|
652 + |
|
653 + | #[tokio::test]
|
654 + | async fn goaway_before_first_stream_has_bounded_reacquisition() {
|
655 + | let script = H2ConnectionScript::new()
|
656 + | .goaway_on_ready()
|
657 + | .fallback(H2StreamScript::respond(H2Response::ok("unexpected")));
|
658 + | let server = H2TestServer::builder()
|
659 + | .connections(H2ConnectionPlan::unbounded(script))
|
660 + | .start()
|
661 + | .await
|
662 + | .expect("H2 server should start");
|
663 + | let client = h2_client(&PartitionedConnectionPool);
|
664 + | let connector = test_client::connector(&client);
|
665 + |
|
666 + | let outcome = tokio::time::timeout(
|
667 + | test_client::WAIT,
|
668 + | test_client::send_request(
|
669 + | &connector,
|
670 + | HttpRequest::get(server.url("/before-first-stream")).expect("valid HTTP request"),
|
671 + | ),
|
672 + | )
|
673 + | .await
|
674 + | .expect("GOAWAY before dispatch did not terminate");
|
675 + | assert!(
|
676 + | outcome.is_err(),
|
677 + | "a request succeeded after GOAWAY excluded its stream"
|
678 + | );
|
679 + | assert!(
|
680 + | server.connection_count() <= 3,
|
681 + | "GOAWAY before dispatch created {} connections",
|
682 + | server.connection_count()
|
683 + | );
|
684 + |
|
685 + | drop(connector);
|
686 + | drop(client);
|
687 + | server.shutdown().await.expect("clean H2 server shutdown");
|
688 + | }
|
586 689 | }
|
587 690 |
|
588 691 | #[cfg(feature = "s2n-tls")]
|
589 692 | mod protocol_negotiation {
|
590 693 | use super::*;
|
591 694 |
|
592 695 | /// The s2n-tls provider negotiates `h2` with ALPN and reuses that connection.
|
593 696 | async fn s2n_negotiates_h2_and_reuses_connection(backend: &dyn HttpsClientBackend) {
|
594 697 | let server = H2TestServer::builder()
|
595 698 | .connections(H2ConnectionPlan::queue([H2ConnectionScript::new()
|
596 699 | .fallback(H2StreamScript::respond(H2Response::ok("s2n-h2")))]))
|
597 700 | .start()
|
598 701 | .await
|
599 702 | .expect("H2 server should start");
|
600 703 | let client = h2_client_with_provider(backend, tls::Provider::S2nTls);
|
601 704 | let connector = test_client::connector(&client);
|
602 705 |
|
603 706 | for request_number in 1..=3 {
|
604 707 | let (status, body) = test_client::get_and_collect(&connector, &server.url("/")).await;
|
605 708 | assert_eq!(status, 200, "request {request_number}");
|
606 709 | assert_eq!(body, b"s2n-h2", "request {request_number}");
|
607 710 | }
|
608 711 |
|
609 712 | let negotiated = server
|
610 713 | .events()
|
611 714 | .into_iter()
|
612 715 | .filter_map(|event| match event {
|
613 716 | H2Event::TlsNegotiated { alpn, .. } => Some(alpn),
|
614 717 | _ => None,
|
615 718 | })
|
616 719 | .collect::<Vec<_>>();
|
617 720 | assert_eq!(negotiated, vec![Some(b"h2".to_vec())]);
|
618 721 | assert_eq!(server.connection_count(), 1);
|
619 722 | assert_eq!(server.stream_count(), 3);
|
620 723 |
|
621 724 | drop(connector);
|
622 725 | drop(client);
|
623 726 | server.shutdown().await.expect("clean H2 server shutdown");
|
624 727 | }
|
625 728 |
|
626 729 | #[tokio::test]
|
627 730 | async fn test_s2n_negotiates_h2_and_reuses_connection_with_hyper_util_legacy_pool() {
|
628 731 | s2n_negotiates_h2_and_reuses_connection(&HyperUtilLegacyPool).await;
|
629 732 | }
|
733 + |
|
734 + | #[tokio::test]
|
735 + | async fn test_s2n_negotiates_h2_and_reuses_connection_with_partitioned_pool() {
|
736 + | s2n_negotiates_h2_and_reuses_connection(&PartitionedConnectionPool).await;
|
737 + | }
|
630 738 | }
|
631 739 |
|
632 740 | mod idle_timeout {
|
633 741 | use super::*;
|
742 + | use aws_smithy_types::body::SdkBody;
|
743 + | use http_body_1x::{Body, Frame, SizeHint};
|
744 + | use std::convert::Infallible;
|
745 + | use std::future::Future;
|
746 + | use std::pin::Pin;
|
747 + | use std::task::{Context, Poll};
|
634 748 | use std::time::Duration;
|
749 + | use tokio::sync::oneshot;
|
635 750 |
|
636 751 | const IDLE_TIMEOUT: Duration = Duration::from_millis(100);
|
637 752 |
|
753 + | /// Streaming request body that remains open until the test releases it.
|
754 + | struct HeldUpload {
|
755 + | finish: oneshot::Receiver<()>,
|
756 + | complete: bool,
|
757 + | }
|
758 + |
|
759 + | impl Body for HeldUpload {
|
760 + | type Data = Bytes;
|
761 + | type Error = Infallible;
|
762 + |
|
763 + | fn poll_frame(
|
764 + | mut self: Pin<&mut Self>,
|
765 + | cx: &mut Context<'_>,
|
766 + | ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
767 + | if self.complete {
|
768 + | return Poll::Ready(None);
|
769 + | }
|
770 + | match Pin::new(&mut self.finish).poll(cx) {
|
771 + | Poll::Ready(_) => {
|
772 + | self.complete = true;
|
773 + | Poll::Ready(None)
|
774 + | }
|
775 + | Poll::Pending => Poll::Pending,
|
776 + | }
|
777 + | }
|
778 + |
|
779 + | fn is_end_stream(&self) -> bool {
|
780 + | self.complete
|
781 + | }
|
782 + |
|
783 + | fn size_hint(&self) -> SizeHint {
|
784 + | SizeHint::default()
|
785 + | }
|
786 + | }
|
787 + |
|
788 + | fn held_upload() -> (oneshot::Sender<()>, SdkBody) {
|
789 + | let (finish, finished) = oneshot::channel();
|
790 + | let body = HeldUpload {
|
791 + | finish: finished,
|
792 + | complete: false,
|
793 + | };
|
794 + | (finish, SdkBody::from_body_1_x(body))
|
795 + | }
|
796 + |
|
638 797 | fn client_with_idle_timeout(backend: &dyn HttpsClientBackend) -> SharedHttpClient {
|
639 798 | backend.build_https(
|
640 799 | BackendConfig {
|
641 800 | pool_idle_timeout: Some(IDLE_TIMEOUT),
|
642 801 | },
|
643 802 | rustls_aws_lc(),
|
644 803 | test_tls::server_tls_context(),
|
645 804 | )
|
646 805 | }
|
647 806 |
|
648 807 | /// An idle H2 connection is closed after its configured timeout and then replaced.
|
649 808 | async fn idle_connection_is_evicted_after_timeout(backend: &dyn HttpsClientBackend) {
|
650 809 | let script =
|
651 810 | H2ConnectionScript::new().fallback(H2StreamScript::respond(H2Response::ok("ok")));
|
652 811 | let server = H2TestServer::builder()
|
653 812 | .connections(H2ConnectionPlan::queue([script.clone(), script]))
|
654 813 | .start()
|
655 814 | .await
|
656 815 | .expect("H2 server should start");
|
657 816 | let client = client_with_idle_timeout(backend);
|
658 817 | let connector = test_client::connector(&client);
|
659 818 |
|
660 819 | let (status, body) = test_client::get_and_collect(&connector, &server.url("/first")).await;
|
661 820 | assert_eq!((status, body.as_slice()), (200, b"ok".as_slice()));
|
662 821 | let first_connection = single_stream_connection(&server, "/first");
|
663 822 | server
|
664 823 | .wait_for_event(test_client::WAIT, |event| {
|
665 824 | matches!(
|
666 825 | event,
|
667 826 | H2Event::ConnectionClosed { connection_id, reason: ConnectionCloseReason::ClientClosed }
|
668 827 | if *connection_id == first_connection
|
669 828 | )
|
670 829 | })
|
671 830 | .await
|
672 831 | .expect("the idle H2 connection should close after its pool timeout");
|
673 832 |
|
674 833 | let (status, body) = test_client::get_and_collect(&connector, &server.url("/second")).await;
|
675 834 | assert_eq!((status, body.as_slice()), (200, b"ok".as_slice()));
|
676 835 | assert_ne!(
|
677 836 | first_connection,
|
678 837 | single_stream_connection(&server, "/second")
|
679 838 | );
|
680 839 |
|
681 840 | drop(connector);
|
682 841 | drop(client);
|
683 842 | server.shutdown().await.expect("clean H2 server shutdown");
|
684 843 | }
|
685 844 |
|
686 845 | #[tokio::test]
|
687 846 | async fn test_idle_connection_is_evicted_after_timeout_with_hyper_util_legacy_pool() {
|
688 847 | idle_connection_is_evicted_after_timeout(&HyperUtilLegacyPool).await;
|
689 848 | }
|
690 849 |
|
850 + | #[tokio::test]
|
851 + | async fn test_idle_connection_is_evicted_after_timeout_with_partitioned_pool() {
|
852 + | idle_connection_is_evicted_after_timeout(&PartitionedConnectionPool).await;
|
853 + | }
|
854 + |
|
691 855 | /// An active stream survives the idle deadline, but the connection is replaced after the
|
692 856 | /// stream completes.
|
693 857 | async fn active_stream_survives_idle_timeout_but_later_request_uses_replacement(
|
694 858 | backend: &dyn HttpsClientBackend,
|
695 859 | ) {
|
696 860 | let held_body_gate = ManualGate::new();
|
697 861 | let script = H2ConnectionScript::new()
|
698 862 | .route(
|
699 863 | "/held",
|
700 864 | H2StreamScript::respond(H2Response::new(http_1x::StatusCode::OK).body(
|
751 915 | }
|
752 916 |
|
753 917 | #[tokio::test]
|
754 918 | async fn test_active_stream_survives_idle_timeout_but_later_request_uses_replacement_with_hyper_util_legacy_pool(
|
755 919 | ) {
|
756 920 | active_stream_survives_idle_timeout_but_later_request_uses_replacement(
|
757 921 | &HyperUtilLegacyPool,
|
758 922 | )
|
759 923 | .await;
|
760 924 | }
|
925 + |
|
926 + | #[tokio::test]
|
927 + | async fn test_active_stream_survives_idle_timeout_but_later_request_uses_replacement_with_partitioned_pool(
|
928 + | ) {
|
929 + | active_stream_survives_idle_timeout_but_later_request_uses_replacement(
|
930 + | &PartitionedConnectionPool,
|
931 + | )
|
932 + | .await;
|
933 + | }
|
934 + |
|
935 + | /// Idle expiration retains a physical connection while its upload remains active.
|
936 + | #[tokio::test]
|
937 + | async fn response_completion_does_not_close_active_upload_at_idle_timeout() {
|
938 + | let held_request_gate = ManualGate::new();
|
939 + | let script = H2ConnectionScript::new()
|
940 + | .route(
|
941 + | "/upload",
|
942 + | H2StreamScript::respond_before_receiving_request_body(
|
943 + | H2Response::ok("early response"),
|
944 + | held_request_gate.waiter(),
|
945 + | ),
|
946 + | )
|
947 + | .route("/reuse", H2StreamScript::respond(H2Response::ok("reused")));
|
948 + | let server = H2TestServer::builder()
|
949 + | .connections(H2ConnectionPlan::queue([script.clone(), script]))
|
950 + | .start()
|
951 + | .await
|
952 + | .expect("H2 server should start");
|
953 + | let client = client_with_idle_timeout(&PartitionedConnectionPool);
|
954 + | let connector = test_client::connector(&client);
|
955 + |
|
956 + | let (finish_upload, upload) = held_upload();
|
957 + | let mut request = HttpRequest::new(upload);
|
958 + | request.set_method("POST").expect("valid HTTP method");
|
959 + | request
|
960 + | .set_uri(server.url("/upload"))
|
961 + | .expect("valid HTTP URI");
|
962 + | let response = test_client::send_request(&connector, request)
|
963 + | .await
|
964 + | .expect("response should arrive before upload completion");
|
965 + | let (status, body) = test_client::collect_response(response).await;
|
966 + | assert_eq!(
|
967 + | (status, body.as_slice()),
|
968 + | (200, b"early response".as_slice())
|
969 + | );
|
970 + |
|
971 + | held_request_gate
|
972 + | .wait_until_reached(test_client::WAIT)
|
973 + | .await
|
974 + | .expect("server should retain the active request body");
|
975 + | let upload_connection = single_stream_connection(&server, "/upload");
|
976 + | tokio::time::sleep(IDLE_TIMEOUT * 2).await;
|
977 + | assert!(
|
978 + | !server.events().iter().any(|event| {
|
979 + | matches!(
|
980 + | event,
|
981 + | H2Event::ConnectionClosed { connection_id, .. }
|
982 + | if *connection_id == upload_connection
|
983 + | )
|
984 + | }),
|
985 + | "idle expiration closed a connection with an active upload"
|
986 + | );
|
987 + |
|
988 + | let (status, body) = test_client::get_and_collect(&connector, &server.url("/reuse")).await;
|
989 + | assert_eq!((status, body.as_slice()), (200, b"reused".as_slice()));
|
990 + | assert_ne!(
|
991 + | upload_connection,
|
992 + | single_stream_connection(&server, "/reuse"),
|
993 + | "an expired HTTP/2 generation accepted a new request"
|
994 + | );
|
995 + | assert_eq!(server.connection_count(), 2);
|
996 + |
|
997 + | finish_upload
|
998 + | .send(())
|
999 + | .expect("request body disappeared before upload completion");
|
1000 + | held_request_gate.release();
|
1001 + | server
|
1002 + | .wait_for_event(test_client::WAIT, |event| {
|
1003 + | matches!(
|
1004 + | event,
|
1005 + | H2Event::ConnectionClosed { connection_id, .. }
|
1006 + | if *connection_id == upload_connection
|
1007 + | )
|
1008 + | })
|
1009 + | .await
|
1010 + | .expect("draining connection should close after upload completion");
|
1011 + | drop(connector);
|
1012 + | drop(client);
|
1013 + | server.shutdown().await.expect("clean H2 server shutdown");
|
1014 + | }
|
1015 + | }
|
1016 + |
|
1017 + | mod partition_reuse {
|
1018 + | use super::*;
|
1019 + | use std::time::Duration;
|
1020 + |
|
1021 + | fn partitioned_clients(
|
1022 + | scope: ConnectionReuseScope,
|
1023 + | ) -> (ConnectionPool, SharedHttpConnector, SharedHttpConnector) {
|
1024 + | let first = PartitionId::from_index(1);
|
1025 + | let second = PartitionId::from_index(2);
|
1026 + | let pool = ConnectionPool::builder()
|
1027 + | .tls_provider(rustls_aws_lc())
|
1028 + | .tls_context(test_tls::server_tls_context())
|
1029 + | .partitions([
|
1030 + | Partition::new(first, TokioDriverSpawner::current()),
|
1031 + | Partition::new(second, TokioDriverSpawner::current()),
|
1032 + | ])
|
1033 + | .connection_reuse_scope(scope)
|
1034 + | .max_connections_per_host(1)
|
1035 + | .build_https()
|
1036 + | .expect("valid partitioned HTTPS pool");
|
1037 + | let first_client = SharedHttpClient::new(
|
1038 + | PoolClient::from_partition(&pool, first).expect("first partition should resolve"),
|
1039 + | );
|
1040 + | let second_client = SharedHttpClient::new(
|
1041 + | PoolClient::from_partition(&pool, second).expect("second partition should resolve"),
|
1042 + | );
|
1043 + | (
|
1044 + | pool,
|
1045 + | test_client::connector(&first_client),
|
1046 + | test_client::connector(&second_client),
|
1047 + | )
|
1048 + | }
|
1049 + |
|
1050 + | async fn eligible_partition_reuses_peer_h2(scope: ConnectionReuseScope) {
|
1051 + | let server = H2TestServer::builder()
|
1052 + | .connections(H2ConnectionPlan::queue([H2ConnectionScript::new()
|
1053 + | .fallback(H2StreamScript::respond(H2Response::ok("shared")))]))
|
1054 + | .start()
|
1055 + | .await
|
1056 + | .expect("H2 server should start");
|
1057 + | let (pool, first, second) = partitioned_clients(scope);
|
1058 + |
|
1059 + | let first_result = test_client::get_and_collect(&first, &server.url("/first")).await;
|
1060 + | let second_result = test_client::get_and_collect(&second, &server.url("/second")).await;
|
1061 + |
|
1062 + | assert_eq!(first_result, (200, b"shared".to_vec()));
|
1063 + | assert_eq!(second_result, (200, b"shared".to_vec()));
|
1064 + | assert_eq!(server.connection_count(), 1);
|
1065 + | assert_eq!(
|
1066 + | single_stream_connection(&server, "/first"),
|
1067 + | single_stream_connection(&server, "/second"),
|
1068 + | "eligible partitions should dispatch through the connection-owning generation"
|
1069 + | );
|
1070 + |
|
1071 + | drop(first);
|
1072 + | drop(second);
|
1073 + | drop(pool);
|
1074 + | server.shutdown().await.expect("clean H2 server shutdown");
|
1075 + | }
|
1076 + |
|
1077 + | #[tokio::test]
|
1078 + | async fn pool_scope_reuses_a_peer_h2_generation() {
|
1079 + | eligible_partition_reuses_peer_h2(ConnectionReuseScope::Pool).await;
|
1080 + | }
|
1081 + |
|
1082 + | #[tokio::test]
|
1083 + | async fn matching_network_interface_scope_reuses_a_peer_h2_generation() {
|
1084 + | eligible_partition_reuses_peer_h2(ConnectionReuseScope::NetworkInterface).await;
|
1085 + | }
|
1086 + |
|
1087 + | #[tokio::test]
|
1088 + | async fn partition_scope_waits_until_out_of_scope_h2_releases_capacity() {
|
1089 + | let script = H2ConnectionScript::new()
|
1090 + | .fallback(H2StreamScript::respond(H2Response::ok("partition")));
|
1091 + | let server = H2TestServer::builder()
|
1092 + | .connections(H2ConnectionPlan::queue([script.clone(), script]))
|
1093 + | .start()
|
1094 + | .await
|
1095 + | .expect("H2 server should start");
|
1096 + | let (pool, first, second) = partitioned_clients(ConnectionReuseScope::Partition);
|
1097 + |
|
1098 + | let (status, body, metadata) =
|
1099 + | get_and_collect_with_capture(&first, &server.url("/first")).await;
|
1100 + | assert_eq!((status, body.as_slice()), (200, b"partition".as_slice()));
|
1101 + | let first_connection = single_stream_connection(&server, "/first");
|
1102 + |
|
1103 + | let second_url = server.url("/second");
|
1104 + | let mut pending =
|
1105 + | tokio::spawn(async move { test_client::get_and_collect(&second, &second_url).await });
|
1106 + | assert!(
|
1107 + | tokio::time::timeout(Duration::from_millis(100), &mut pending)
|
1108 + | .await
|
1109 + | .is_err(),
|
1110 + | "an out-of-scope generation must not satisfy partition-local demand"
|
1111 + | );
|
1112 + | assert_eq!(server.connection_count(), 1);
|
1113 + |
|
1114 + | metadata.poison();
|
1115 + | let (status, body) = tokio::time::timeout(test_client::WAIT, pending)
|
1116 + | .await
|
1117 + | .expect("released capacity should wake partition-local demand")
|
1118 + | .expect("request task should not panic");
|
1119 + | assert_eq!((status, body.as_slice()), (200, b"partition".as_slice()));
|
1120 + | let second_connection = single_stream_connection(&server, "/second");
|
1121 + | assert_ne!(first_connection, second_connection);
|
1122 + | assert_eq!(server.connection_count(), 2);
|
1123 + |
|
1124 + | drop(metadata);
|
1125 + | drop(first);
|
1126 + | drop(pool);
|
1127 + | server.shutdown().await.expect("clean H2 server shutdown");
|
1128 + | }
|
761 1129 | }
|