1 + | /*
|
2 + | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
3 + | * SPDX-License-Identifier: Apache-2.0
|
4 + | */
|
5 + |
|
6 + | //! Pool behavior tests parameterized over HTTP client implementations.
|
7 + | //!
|
8 + | //! Each test is written once as an `async fn` that takes a `&dyn MakeClient`,
|
9 + | //! then invoked for each client implementation.
|
10 + |
|
11 + | #![cfg(all(feature = "wire-mock", feature = "default-client"))]
|
12 + |
|
13 + | use aws_smithy_async::time::SystemTimeSource;
|
14 + | use aws_smithy_http_client::pool::{Client as PoolClient, SharedPool};
|
15 + | use aws_smithy_http_client::test_util::wire::connection::{
|
16 + | ConnectionBehavior, ConnectionTestHarness,
|
17 + | };
|
18 + | use aws_smithy_http_client::test_util::wire::{ReplayedEvent, WireMockServer};
|
19 + | use aws_smithy_http_client::{ev, match_events, Builder, Connector};
|
20 + | use aws_smithy_runtime_api::client::http::{
|
21 + | HttpClient, HttpConnector, HttpConnectorSettings, SharedHttpClient,
|
22 + | };
|
23 + | use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
24 + | use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
|
25 + | use aws_smithy_runtime_api::shared::IntoShared;
|
26 + | use std::borrow::Cow;
|
27 + | use std::net::{IpAddr, Ipv4Addr};
|
28 + | use std::time::Duration;
|
29 + |
|
30 + | const IP1: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
31 + |
|
32 + | // ---------------------------------------------------------------------------
|
33 + | // ClientConfig + MakeClient
|
34 + | // ---------------------------------------------------------------------------
|
35 + |
|
36 + | #[derive(Clone, Default)]
|
37 + | struct ClientConfig {
|
38 + | idle_timeout: Option<Duration>,
|
39 + | max_connections: Option<usize>,
|
40 + | max_connections_per_host: Option<usize>,
|
41 + | }
|
42 + |
|
43 + | impl ClientConfig {
|
44 + | fn with_idle_timeout(mut self, timeout: Duration) -> Self {
|
45 + | self.idle_timeout = Some(timeout);
|
46 + | self
|
47 + | }
|
48 + |
|
49 + | fn with_max_connections(mut self, n: usize) -> Self {
|
50 + | self.max_connections = Some(n);
|
51 + | self
|
52 + | }
|
53 + |
|
54 + | fn with_max_connections_per_host(mut self, n: usize) -> Self {
|
55 + | self.max_connections_per_host = Some(n);
|
56 + | self
|
57 + | }
|
58 + | }
|
59 + |
|
60 + | trait MakeClient: Send + Sync {
|
61 + | fn make(&self, config: ClientConfig) -> SharedHttpClient;
|
62 + | }
|
63 + |
|
64 + | /// Current hyper 1.x client via `Builder::new()`
|
65 + | struct V1Client;
|
66 + |
|
67 + | impl MakeClient for V1Client {
|
68 + | fn make(&self, config: ClientConfig) -> SharedHttpClient {
|
69 + | Builder::new().build_with_connector_fn(move |_settings, _components| {
|
70 + | let mut builder = Connector::builder();
|
71 + | if let Some(timeout) = config.idle_timeout {
|
72 + | builder = builder.pool_idle_timeout(timeout);
|
73 + | }
|
74 + | // v1 does not support max_connections
|
75 + | builder.build_http()
|
76 + | })
|
77 + | }
|
78 + | }
|
79 + |
|
80 + | // V2 client backed by the composable connection pool
|
81 + | struct V2Client;
|
82 + |
|
83 + | impl MakeClient for V2Client {
|
84 + | fn make(&self, config: ClientConfig) -> SharedHttpClient {
|
85 + | let mut builder = SharedPool::builder();
|
86 + | if let Some(timeout) = config.idle_timeout {
|
87 + | builder = builder.pool_idle_timeout(timeout);
|
88 + | }
|
89 + | if let Some(n) = config.max_connections {
|
90 + | builder = builder.max_connections(n);
|
91 + | }
|
92 + | if let Some(n) = config.max_connections_per_host {
|
93 + | builder = builder.max_connections_per_host(n);
|
94 + | }
|
95 + | let pool = builder.build_http();
|
96 + | PoolClient::new(&pool).into_shared()
|
97 + | }
|
98 + | }
|
99 + |
|
100 + | // ---------------------------------------------------------------------------
|
101 + | // Helpers
|
102 + | // ---------------------------------------------------------------------------
|
103 + |
|
104 + | fn runtime_components() -> aws_smithy_runtime_api::client::runtime_components::RuntimeComponents {
|
105 + | RuntimeComponentsBuilder::for_tests()
|
106 + | .with_time_source(Some(SystemTimeSource::new()))
|
107 + | .build()
|
108 + | .expect("valid runtime components")
|
109 + | }
|
110 + |
|
111 + | async fn send_to(
|
112 + | client: &SharedHttpClient,
|
113 + | url: &str,
|
114 + | ) -> Result<
|
115 + | aws_smithy_runtime_api::client::orchestrator::HttpResponse,
|
116 + | aws_smithy_runtime_api::client::result::ConnectorError,
|
117 + | > {
|
118 + | let settings = HttpConnectorSettings::builder().build();
|
119 + | let components = runtime_components();
|
120 + | let connector = client.http_connector(&settings, &components);
|
121 + | connector
|
122 + | .call(HttpRequest::get(url).expect("valid HTTP request"))
|
123 + | .await
|
124 + | }
|
125 + |
|
126 + | /// Send a request and read the response body to completion.
|
127 + | ///
|
128 + | /// This ensures the connection's body guard is released, returning the
|
129 + | /// connection to the pool. Returns `(status, body_bytes)`.
|
130 + | async fn send_and_read_body(
|
131 + | client: &SharedHttpClient,
|
132 + | url: &str,
|
133 + | ) -> Result<(u16, Vec<u8>), aws_smithy_runtime_api::client::result::ConnectorError> {
|
134 + | use http_body_util::BodyExt;
|
135 + | let resp = send_to(client, url).await?;
|
136 + | let status = resp.status().as_u16();
|
137 + | let body = resp
|
138 + | .into_body()
|
139 + | .collect()
|
140 + | .await
|
141 + | .expect("body should be readable")
|
142 + | .to_bytes()
|
143 + | .to_vec();
|
144 + | Ok((status, body))
|
145 + | }
|
146 + |
|
147 + | fn localhost_url(server: &WireMockServer) -> String {
|
148 + | let endpoint = server.endpoint_url();
|
149 + | let port = endpoint
|
150 + | .rsplit(':')
|
151 + | .next()
|
152 + | .expect("endpoint URL should contain port");
|
153 + | format!("http://127.0.0.1:{port}/")
|
154 + | }
|
155 + |
|
156 + | // ---------------------------------------------------------------------------
|
157 + | // Test implementations
|
158 + | // ---------------------------------------------------------------------------
|
159 + |
|
160 + | /// Connection reuse via HTTP/1.1 keep-alive: sequential requests reuse one TCP connection.
|
161 + | async fn connection_reuse(make: &dyn MakeClient) {
|
162 + | let server = WireMockServer::start(vec![
|
163 + | ReplayedEvent::status(200),
|
164 + | ReplayedEvent::status(200),
|
165 + | ReplayedEvent::status(200),
|
166 + | ])
|
167 + | .await;
|
168 + |
|
169 + | let client = make.make(ClientConfig::default());
|
170 + | let url = localhost_url(&server);
|
171 + |
|
172 + | for i in 1..=3 {
|
173 + | let resp = send_to(&client, &url)
|
174 + | .await
|
175 + | .unwrap_or_else(|e| panic!("request {i} should succeed: {e}"));
|
176 + | assert_eq!(resp.status().as_u16(), 200, "request {i} should return 200");
|
177 + | }
|
178 + |
|
179 + | match_events!(ev!(connect), ev!(http(200)), ev!(http(200)), ev!(http(200)))(&server.events());
|
180 + | }
|
181 + |
|
182 + | /// Idle timeout eviction: a connection idle past the timeout is discarded.
|
183 + | async fn idle_timeout_eviction(make: &dyn MakeClient) {
|
184 + | let server =
|
185 + | WireMockServer::start(vec![ReplayedEvent::status(200), ReplayedEvent::status(200)]).await;
|
186 + |
|
187 + | let idle_timeout = Duration::from_millis(100);
|
188 + | let client = make.make(ClientConfig::default().with_idle_timeout(idle_timeout));
|
189 + | let url = localhost_url(&server);
|
190 + |
|
191 + | let status = send_to(&client, &url)
|
192 + | .await
|
193 + | .expect("first request should succeed")
|
194 + | .status()
|
195 + | .as_u16();
|
196 + | assert_eq!(status, 200);
|
197 + | // Response (and its body guard) dropped here — connection returns to pool.
|
198 + |
|
199 + | tokio::time::sleep(idle_timeout * 2).await;
|
200 + |
|
201 + | let status = send_to(&client, &url)
|
202 + | .await
|
203 + | .expect("second request should succeed after idle eviction")
|
204 + | .status()
|
205 + | .as_u16();
|
206 + | assert_eq!(status, 200);
|
207 + |
|
208 + | match_events!(ev!(connect), ev!(http(200)), ev!(connect), ev!(http(200)))(&server.events());
|
209 + | }
|
210 + |
|
211 + | /// A server that resets on connect should surface as a connector error.
|
212 + | async fn connection_reset_returns_error(make: &dyn MakeClient) {
|
213 + | let harness = ConnectionTestHarness::builder()
|
214 + | .endpoint(IP1, vec![ConnectionBehavior::ResetOnConnect])
|
215 + | .build()
|
216 + | .await;
|
217 + |
|
218 + | let client = make.make(ClientConfig::default());
|
219 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
220 + |
|
221 + | let err = send_to(&client, &url)
|
222 + | .await
|
223 + | .expect_err("request to a reset-on-connect endpoint should fail");
|
224 + | assert!(err.is_io(), "expected ConnectorError::io, got: {err:?}");
|
225 + | }
|
226 + |
|
227 + | /// The client should report correct connector metadata.
|
228 + | async fn connector_metadata(make: &dyn MakeClient) {
|
229 + | let client = make.make(ClientConfig::default());
|
230 + | let metadata = client
|
231 + | .connector_metadata()
|
232 + | .expect("connector_metadata should return Some");
|
233 + | assert_eq!(metadata.name(), Cow::Borrowed("hyper"));
|
234 + | assert_eq!(
|
235 + | metadata.version(),
|
236 + | Some(Cow::Borrowed("1.x")),
|
237 + | "expected hyper 1.x connector version"
|
238 + | );
|
239 + | }
|
240 + |
|
241 + | // ---------------------------------------------------------------------------
|
242 + | // v1 test runners
|
243 + | // ---------------------------------------------------------------------------
|
244 + |
|
245 + | #[tokio::test]
|
246 + | async fn v1_connection_reuse() {
|
247 + | connection_reuse(&V1Client).await;
|
248 + | }
|
249 + |
|
250 + | #[tokio::test]
|
251 + | async fn v1_idle_timeout_eviction() {
|
252 + | idle_timeout_eviction(&V1Client).await;
|
253 + | }
|
254 + |
|
255 + | #[tokio::test]
|
256 + | async fn v1_connection_reset_returns_error() {
|
257 + | connection_reset_returns_error(&V1Client).await;
|
258 + | }
|
259 + |
|
260 + | #[tokio::test]
|
261 + | async fn v1_connector_metadata() {
|
262 + | connector_metadata(&V1Client).await;
|
263 + | }
|
264 + |
|
265 + | // ---------------------------------------------------------------------------
|
266 + | // v2 test runners
|
267 + | // ---------------------------------------------------------------------------
|
268 + |
|
269 + | #[tokio::test]
|
270 + | async fn v2_connection_reuse() {
|
271 + | connection_reuse(&V2Client).await;
|
272 + | }
|
273 + |
|
274 + | #[tokio::test]
|
275 + | async fn v2_idle_timeout_eviction() {
|
276 + | // An idle connection is evicted after the pool idle timeout, so a request
|
277 + | // arriving after the timeout opens a fresh connection. The body of the
|
278 + | // first response is fully consumed, so its connection returns to the pool
|
279 + | // and idles (the eviction target) rather than staying checked out.
|
280 + | let idle_timeout = Duration::from_millis(100);
|
281 + |
|
282 + | let harness = ConnectionTestHarness::builder()
|
283 + | .endpoint(
|
284 + | IP1,
|
285 + | vec![
|
286 + | ConnectionBehavior::RespondKeepAlive {
|
287 + | status: 200,
|
288 + | body: b"first",
|
289 + | },
|
290 + | ConnectionBehavior::RespondKeepAlive {
|
291 + | status: 200,
|
292 + | body: b"second",
|
293 + | },
|
294 + | ],
|
295 + | )
|
296 + | .build()
|
297 + | .await;
|
298 + |
|
299 + | let pool = SharedPool::builder()
|
300 + | .dns_resolver(harness.dns_resolver())
|
301 + | .pool_idle_timeout(idle_timeout)
|
302 + | .build_http();
|
303 + | let client = PoolClient::new(&pool).into_shared();
|
304 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
305 + |
|
306 + | // First request: body consumed, connection returns to the pool idle. This
|
307 + | // also lazily spawns the eviction task (pool_idle_timeout is set).
|
308 + | let (status, _) = send_and_read_body(&client, &url)
|
309 + | .await
|
310 + | .expect("first request should succeed");
|
311 + | assert_eq!(status, 200);
|
312 + | assert_eq!(harness.tcp_accepted_count(), 1);
|
313 + |
|
314 + | // Wait past the idle timeout so the eviction task drops the idle connection.
|
315 + | tokio::time::sleep(idle_timeout * 3).await;
|
316 + |
|
317 + | // The next request cannot reuse the evicted connection and opens a new one.
|
318 + | let (status, _) = send_and_read_body(&client, &url)
|
319 + | .await
|
320 + | .expect("second request should succeed after idle eviction");
|
321 + | assert_eq!(status, 200);
|
322 + | assert_eq!(
|
323 + | harness.tcp_accepted_count(),
|
324 + | 2,
|
325 + | "the idle connection was evicted, so the second request reconnects"
|
326 + | );
|
327 + | }
|
328 + |
|
329 + | #[tokio::test]
|
330 + | async fn v2_connection_reset_returns_error() {
|
331 + | connection_reset_returns_error(&V2Client).await;
|
332 + | }
|
333 + |
|
334 + | #[tokio::test]
|
335 + | async fn v2_connector_metadata() {
|
336 + | connector_metadata(&V2Client).await;
|
337 + | }
|
338 + |
|
339 + | // ---------------------------------------------------------------------------
|
340 + | // Test implementations: origin-form URI + Host header
|
341 + | // ---------------------------------------------------------------------------
|
342 + |
|
343 + | /// Requests should be sent with origin-form URI (just the path) and a correct Host header.
|
344 + | async fn origin_form_and_host_header(make: &dyn MakeClient) {
|
345 + | let harness = ConnectionTestHarness::builder()
|
346 + | .endpoint(
|
347 + | IP1,
|
348 + | vec![ConnectionBehavior::RespondKeepAlive {
|
349 + | status: 200,
|
350 + | body: b"ok",
|
351 + | }],
|
352 + | )
|
353 + | .build()
|
354 + | .await;
|
355 + |
|
356 + | let client = make.make(ClientConfig::default());
|
357 + | let port = harness.endpoints[0].port();
|
358 + | let url = format!("http://127.0.0.1:{port}/some/path?key=val");
|
359 + |
|
360 + | let resp = send_to(&client, &url)
|
361 + | .await
|
362 + | .expect("request should succeed");
|
363 + | assert_eq!(resp.status().as_u16(), 200);
|
364 + |
|
365 + | let requests = harness.http_requests();
|
366 + | assert_eq!(requests.len(), 1, "expected exactly one HTTP request");
|
367 + | let (uri, host) = &requests[0];
|
368 + |
|
369 + | // URI must be origin-form (path + query only, no scheme/authority)
|
370 + | assert_eq!(uri, "/some/path?key=val", "URI should be origin-form");
|
371 + |
|
372 + | // Host header must be present with the correct authority
|
373 + | let host = host.as_deref().expect("Host header should be present");
|
374 + | assert_eq!(
|
375 + | host,
|
376 + | format!("127.0.0.1:{port}"),
|
377 + | "Host header should match authority"
|
378 + | );
|
379 + | }
|
380 + |
|
381 + | #[tokio::test]
|
382 + | async fn v1_origin_form_and_host_header() {
|
383 + | origin_form_and_host_header(&V1Client).await;
|
384 + | }
|
385 + |
|
386 + | #[tokio::test]
|
387 + | async fn v2_origin_form_and_host_header() {
|
388 + | origin_form_and_host_header(&V2Client).await;
|
389 + | }
|
390 + |
|
391 + | // ---------------------------------------------------------------------------
|
392 + | // Test implementations: max_connections
|
393 + | // ---------------------------------------------------------------------------
|
394 + |
|
395 + | /// With max_connections(2), concurrent requests should not open more than 2 connections,
|
396 + | /// and all requests should succeed (waiting requests served via connection reuse).
|
397 + | async fn max_connections_limits_concurrency(make: &dyn MakeClient) {
|
398 + | let harness = ConnectionTestHarness::builder()
|
399 + | .endpoint(
|
400 + | IP1,
|
401 + | (0..10)
|
402 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
403 + | status: 200,
|
404 + | body: b"ok",
|
405 + | })
|
406 + | .collect(),
|
407 + | )
|
408 + | .build()
|
409 + | .await;
|
410 + |
|
411 + | let client = make.make(ClientConfig::default().with_max_connections(2));
|
412 + | let port = harness.endpoints[0].port();
|
413 + | let url = format!("http://127.0.0.1:{port}/");
|
414 + |
|
415 + | // Send 5 concurrent requests — all should succeed
|
416 + | let mut tasks = tokio::task::JoinSet::new();
|
417 + | for _ in 0..5 {
|
418 + | let client = client.clone();
|
419 + | let url = url.clone();
|
420 + | tasks.spawn(async move { send_to(&client, &url).await });
|
421 + | }
|
422 + | let mut success_count = 0;
|
423 + | while let Some(result) = tasks.join_next().await {
|
424 + | let resp = result
|
425 + | .expect("task should not panic")
|
426 + | .expect("request should succeed");
|
427 + | assert_eq!(resp.status().as_u16(), 200);
|
428 + | success_count += 1;
|
429 + | }
|
430 + | assert_eq!(success_count, 5, "all 5 requests should complete");
|
431 + |
|
432 + | let accepted = harness.tcp_accepted_count();
|
433 + | assert!(
|
434 + | accepted <= 2,
|
435 + | "expected at most 2 connections with max_connections(2), got {accepted}"
|
436 + | );
|
437 + | }
|
438 + |
|
439 + | /// Cached (reused) connections don't consume permits — sequential requests
|
440 + | /// with max_connections(1) all succeed on one connection.
|
441 + | async fn max_connections_reuse_does_not_consume_permits(make: &dyn MakeClient) {
|
442 + | let harness = ConnectionTestHarness::builder()
|
443 + | .endpoint(
|
444 + | IP1,
|
445 + | (0..5)
|
446 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
447 + | status: 200,
|
448 + | body: b"ok",
|
449 + | })
|
450 + | .collect(),
|
451 + | )
|
452 + | .build()
|
453 + | .await;
|
454 + |
|
455 + | let client = make.make(ClientConfig::default().with_max_connections(1));
|
456 + | let port = harness.endpoints[0].port();
|
457 + | let url = format!("http://127.0.0.1:{port}/");
|
458 + |
|
459 + | // 5 sequential requests — all reuse the same connection
|
460 + | for i in 0..5 {
|
461 + | let resp = send_to(&client, &url)
|
462 + | .await
|
463 + | .unwrap_or_else(|e| panic!("request {i} should succeed: {e}"));
|
464 + | assert_eq!(resp.status().as_u16(), 200);
|
465 + | }
|
466 + |
|
467 + | assert_eq!(
|
468 + | harness.tcp_accepted_count(),
|
469 + | 1,
|
470 + | "all requests should reuse one connection"
|
471 + | );
|
472 + | }
|
473 + |
|
474 + | #[tokio::test]
|
475 + | async fn v2_max_connections_limits_concurrency() {
|
476 + | max_connections_limits_concurrency(&V2Client).await;
|
477 + | }
|
478 + |
|
479 + | #[tokio::test]
|
480 + | async fn v2_max_connections_reuse_does_not_consume_permits() {
|
481 + | max_connections_reuse_does_not_consume_permits(&V2Client).await;
|
482 + | }
|
483 + |
|
484 + | // ---------------------------------------------------------------------------
|
485 + | // Test implementations: max_connections_per_host
|
486 + | // ---------------------------------------------------------------------------
|
487 + |
|
488 + | async fn is_bindable(ip: IpAddr) -> bool {
|
489 + | tokio::net::TcpListener::bind((ip, 0u16)).await.is_ok()
|
490 + | }
|
491 + |
|
492 + | const IP2: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
|
493 + |
|
494 + | /// Per-host limit: each host gets its own budget.
|
495 + | async fn max_connections_per_host_limits_per_host(make: &dyn MakeClient) {
|
496 + | if !is_bindable(IP2).await {
|
497 + | eprintln!("skipping test: 127.0.0.2 not bindable");
|
498 + | return;
|
499 + | }
|
500 + |
|
501 + | let harness = ConnectionTestHarness::builder()
|
502 + | .endpoint(
|
503 + | IP1,
|
504 + | (0..10)
|
505 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
506 + | status: 200,
|
507 + | body: b"ok",
|
508 + | })
|
509 + | .collect(),
|
510 + | )
|
511 + | .endpoint(
|
512 + | IP2,
|
513 + | (0..10)
|
514 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
515 + | status: 200,
|
516 + | body: b"ok",
|
517 + | })
|
518 + | .collect(),
|
519 + | )
|
520 + | .build()
|
521 + | .await;
|
522 + |
|
523 + | let client = make.make(ClientConfig::default().with_max_connections_per_host(2));
|
524 + | let port = harness.endpoints[0].port();
|
525 + |
|
526 + | // Send 4 concurrent requests to each host
|
527 + | let mut tasks = tokio::task::JoinSet::new();
|
528 + | for ip in ["127.0.0.1", "127.0.0.2"] {
|
529 + | for _ in 0..4 {
|
530 + | let client = client.clone();
|
531 + | let url = format!("http://{ip}:{port}/");
|
532 + | tasks.spawn(async move { send_to(&client, &url).await });
|
533 + | }
|
534 + | }
|
535 + | while let Some(result) = tasks.join_next().await {
|
536 + | result
|
537 + | .expect("task should not panic")
|
538 + | .expect("request should succeed");
|
539 + | }
|
540 + |
|
541 + | let host1 = harness.tcp_accepted_by(IP1);
|
542 + | let host2 = harness.tcp_accepted_by(IP2);
|
543 + | assert!(host1 <= 2, "host 1: expected ≤2 connections, got {host1}");
|
544 + | assert!(host2 <= 2, "host 2: expected ≤2 connections, got {host2}");
|
545 + | // Total exceeds per-host limit (no global limit set)
|
546 + | assert!(host1 + host2 > 2, "total should exceed per-host limit");
|
547 + | }
|
548 + |
|
549 + | /// Global and per-host limits compose: global(3) + per_host(2) with 2 hosts.
|
550 + | async fn max_connections_global_and_per_host_compose(make: &dyn MakeClient) {
|
551 + | if !is_bindable(IP2).await {
|
552 + | eprintln!("skipping test: 127.0.0.2 not bindable");
|
553 + | return;
|
554 + | }
|
555 + |
|
556 + | let harness = ConnectionTestHarness::builder()
|
557 + | .endpoint(
|
558 + | IP1,
|
559 + | (0..10)
|
560 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
561 + | status: 200,
|
562 + | body: b"ok",
|
563 + | })
|
564 + | .collect(),
|
565 + | )
|
566 + | .endpoint(
|
567 + | IP2,
|
568 + | (0..10)
|
569 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
570 + | status: 200,
|
571 + | body: b"ok",
|
572 + | })
|
573 + | .collect(),
|
574 + | )
|
575 + | .build()
|
576 + | .await;
|
577 + |
|
578 + | let client = make.make(
|
579 + | ClientConfig::default()
|
580 + | .with_max_connections(3)
|
581 + | .with_max_connections_per_host(2),
|
582 + | );
|
583 + | let port = harness.endpoints[0].port();
|
584 + |
|
585 + | // Send 4 concurrent requests to each host (8 total)
|
586 + | let mut tasks = tokio::task::JoinSet::new();
|
587 + | for ip in ["127.0.0.1", "127.0.0.2"] {
|
588 + | for _ in 0..4 {
|
589 + | let client = client.clone();
|
590 + | let url = format!("http://{ip}:{port}/");
|
591 + | tasks.spawn(async move { send_to(&client, &url).await });
|
592 + | }
|
593 + | }
|
594 + | while let Some(result) = tasks.join_next().await {
|
595 + | result
|
596 + | .expect("task should not panic")
|
597 + | .expect("request should succeed");
|
598 + | }
|
599 + |
|
600 + | let host1 = harness.tcp_accepted_by(IP1);
|
601 + | let host2 = harness.tcp_accepted_by(IP2);
|
602 + | // Per-host: each ≤2
|
603 + | assert!(host1 <= 2, "host 1: expected ≤2, got {host1}");
|
604 + | assert!(host2 <= 2, "host 2: expected ≤2, got {host2}");
|
605 + | // Global: total ≤3
|
606 + | assert!(
|
607 + | host1 + host2 <= 3,
|
608 + | "total: expected ≤3 (global limit), got {}",
|
609 + | host1 + host2
|
610 + | );
|
611 + | }
|
612 + |
|
613 + | #[tokio::test]
|
614 + | async fn v2_max_connections_per_host_limits_per_host() {
|
615 + | max_connections_per_host_limits_per_host(&V2Client).await;
|
616 + | }
|
617 + |
|
618 + | #[tokio::test]
|
619 + | async fn v2_max_connections_global_and_per_host_compose() {
|
620 + | max_connections_global_and_per_host_compose(&V2Client).await;
|
621 + | }
|
622 + |
|
623 + | // ---------------------------------------------------------------------------
|
624 + | // Test implementations: connection lifecycle
|
625 + | // ---------------------------------------------------------------------------
|
626 + |
|
627 + | /// Without max_connections, connections scale freely under concurrency.
|
628 + | async fn no_limit_allows_unbounded_connections(make: &dyn MakeClient) {
|
629 + | let harness = ConnectionTestHarness::builder()
|
630 + | .endpoint(
|
631 + | IP1,
|
632 + | (0..20)
|
633 + | .map(|_| ConnectionBehavior::HoldThenClose(Duration::from_secs(2)))
|
634 + | .collect(),
|
635 + | )
|
636 + | .build()
|
637 + | .await;
|
638 + |
|
639 + | let client = make.make(ClientConfig::default());
|
640 + | let port = harness.endpoints[0].port();
|
641 + | let url = format!("http://127.0.0.1:{port}/");
|
642 + |
|
643 + | // Send 5 concurrent requests — server holds each connection open,
|
644 + | // so the client must open 5 separate connections.
|
645 + | let mut tasks = tokio::task::JoinSet::new();
|
646 + | for _ in 0..5 {
|
647 + | let client = client.clone();
|
648 + | let url = url.clone();
|
649 + | tasks.spawn(async move {
|
650 + | // These will fail (server doesn't send HTTP response), but
|
651 + | // the point is that 5 TCP connections are opened concurrently.
|
652 + | let _ = send_to(&client, &url).await;
|
653 + | });
|
654 + | }
|
655 + | // Give time for all connections to be established
|
656 + | tokio::time::sleep(Duration::from_millis(200)).await;
|
657 + |
|
658 + | let accepted = harness.tcp_accepted_count();
|
659 + | assert!(
|
660 + | accepted >= 4,
|
661 + | "without max_connections, expected ≥4 concurrent connections, got {accepted}"
|
662 + | );
|
663 + |
|
664 + | // Clean up tasks
|
665 + | tasks.shutdown().await;
|
666 + | }
|
667 + |
|
668 + | /// Sequential requests reuse the same connection (body fully consumed between requests).
|
669 + | async fn connection_reuse_after_body_consumed(make: &dyn MakeClient) {
|
670 + | let harness = ConnectionTestHarness::builder()
|
671 + | .endpoint(
|
672 + | IP1,
|
673 + | (0..3)
|
674 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
675 + | status: 200,
|
676 + | body: b"hello world response body",
|
677 + | })
|
678 + | .collect(),
|
679 + | )
|
680 + | .build()
|
681 + | .await;
|
682 + |
|
683 + | let client = make.make(ClientConfig::default());
|
684 + | let port = harness.endpoints[0].port();
|
685 + | let url = format!("http://127.0.0.1:{port}/");
|
686 + |
|
687 + | for i in 0..3 {
|
688 + | let (status, body) = send_and_read_body(&client, &url)
|
689 + | .await
|
690 + | .unwrap_or_else(|e| panic!("request {i} should succeed: {e}"));
|
691 + | assert_eq!(status, 200);
|
692 + | assert_eq!(body, b"hello world response body");
|
693 + | }
|
694 + |
|
695 + | assert_eq!(
|
696 + | harness.tcp_accepted_count(),
|
697 + | 1,
|
698 + | "all requests should reuse one connection after body is consumed"
|
699 + | );
|
700 + | }
|
701 + |
|
702 + | /// Back-to-back requests reuse one connection despite a short idle timeout:
|
703 + | /// each re-checkout beats the eviction tick, so no reconnect occurs.
|
704 + | async fn active_connection_survives_idle_timeout(make: &dyn MakeClient) {
|
705 + | let harness = ConnectionTestHarness::builder()
|
706 + | .endpoint(
|
707 + | IP1,
|
708 + | vec![
|
709 + | ConnectionBehavior::RespondKeepAlive {
|
710 + | status: 200,
|
711 + | body: b"first",
|
712 + | },
|
713 + | ConnectionBehavior::RespondKeepAlive {
|
714 + | status: 200,
|
715 + | body: b"second",
|
716 + | },
|
717 + | ConnectionBehavior::RespondKeepAlive {
|
718 + | status: 200,
|
719 + | body: b"third",
|
720 + | },
|
721 + | ],
|
722 + | )
|
723 + | .build()
|
724 + | .await;
|
725 + |
|
726 + | // Use a very short idle timeout — but sequential requests should still
|
727 + | // reuse the connection because the pool doesn't evict between requests
|
728 + | // that happen back-to-back.
|
729 + | let client = make.make(ClientConfig::default().with_idle_timeout(Duration::from_millis(100)));
|
730 + | let port = harness.endpoints[0].port();
|
731 + | let url = format!("http://127.0.0.1:{port}/");
|
732 + |
|
733 + | // Three back-to-back requests — all should reuse the same connection
|
734 + | let expected = [b"first".as_slice(), b"second", b"third"];
|
735 + | for (i, expected_body) in expected.iter().enumerate() {
|
736 + | let (status, body) = send_and_read_body(&client, &url)
|
737 + | .await
|
738 + | .unwrap_or_else(|e| panic!("request {i} should succeed: {e}"));
|
739 + | assert_eq!(status, 200);
|
740 + | assert_eq!(body, *expected_body, "request {i} body mismatch");
|
741 + | }
|
742 + |
|
743 + | assert_eq!(
|
744 + | harness.tcp_accepted_count(),
|
745 + | 1,
|
746 + | "back-to-back requests should reuse one connection even with short idle timeout"
|
747 + | );
|
748 + | }
|
749 + |
|
750 + | #[tokio::test]
|
751 + | async fn v2_no_limit_allows_unbounded_connections() {
|
752 + | no_limit_allows_unbounded_connections(&V2Client).await;
|
753 + | }
|
754 + |
|
755 + | #[tokio::test]
|
756 + | async fn v2_connection_reuse_after_body_consumed() {
|
757 + | connection_reuse_after_body_consumed(&V2Client).await;
|
758 + | }
|
759 + |
|
760 + | #[tokio::test]
|
761 + | async fn v2_active_connection_survives_idle_timeout() {
|
762 + | active_connection_survives_idle_timeout(&V2Client).await;
|
763 + | }
|
764 + |
|
765 + | // ---------------------------------------------------------------------------
|
766 + | // Test implementations: connection lifecycle
|
767 + | // ---------------------------------------------------------------------------
|
768 + |
|
769 + | /// Server closes an idle connection (simulating server-side idle timeout).
|
770 + | /// The connection returns to the pool clean, then dies while idle. On next
|
771 + | /// checkout, poll_ready detects the dead connection, the checkout loop
|
772 + | /// discards it, and a fresh connection is created.
|
773 + | async fn stale_connection_detected_at_checkout(make: &dyn MakeClient) {
|
774 + | let harness = ConnectionTestHarness::builder()
|
775 + | .endpoint(
|
776 + | IP1,
|
777 + | vec![
|
778 + | // First connection: respond with keep-alive, then close after 30ms
|
779 + | ConnectionBehavior::RespondThenIdleClose {
|
780 + | status: 200,
|
781 + | body: b"first",
|
782 + | idle: Duration::from_millis(30),
|
783 + | },
|
784 + | // Second connection (after stale one is discarded)
|
785 + | ConnectionBehavior::RespondKeepAlive {
|
786 + | status: 200,
|
787 + | body: b"second",
|
788 + | },
|
789 + | ],
|
790 + | )
|
791 + | .build()
|
792 + | .await;
|
793 + |
|
794 + | let client = make.make(ClientConfig::default());
|
795 + | let port = harness.endpoints[0].port();
|
796 + | let url = format!("http://127.0.0.1:{port}/");
|
797 + |
|
798 + | // First request succeeds, body consumed, connection returns to pool
|
799 + | let (status, body) = send_and_read_body(&client, &url)
|
800 + | .await
|
801 + | .expect("first request");
|
802 + | assert_eq!(status, 200);
|
803 + | assert_eq!(body, b"first");
|
804 + |
|
805 + | // Wait for server to close the idle connection (30ms idle + margin)
|
806 + | tokio::time::sleep(Duration::from_millis(80)).await;
|
807 + |
|
808 + | // Second request: checkout finds stale connection, discards, creates new
|
809 + | let (status, body) = send_and_read_body(&client, &url)
|
810 + | .await
|
811 + | .expect("second request should succeed on fresh connection");
|
812 + | assert_eq!(status, 200);
|
813 + | assert_eq!(body, b"second");
|
814 + |
|
815 + | assert_eq!(
|
816 + | harness.tcp_accepted_count(),
|
817 + | 2,
|
818 + | "should have opened 2 connections (first died while idle in pool)"
|
819 + | );
|
820 + | }
|
821 + |
|
822 + | /// When the response body is not consumed, the connection is held by the
|
823 + | /// body guard and unavailable for reuse. A concurrent request must open
|
824 + | /// a new connection.
|
825 + | async fn unconsumed_body_holds_connection(make: &dyn MakeClient) {
|
826 + | let harness = ConnectionTestHarness::builder()
|
827 + | .endpoint(
|
828 + | IP1,
|
829 + | vec![
|
830 + | ConnectionBehavior::RespondKeepAlive {
|
831 + | status: 200,
|
832 + | body: b"first",
|
833 + | },
|
834 + | ConnectionBehavior::RespondKeepAlive {
|
835 + | status: 200,
|
836 + | body: b"second",
|
837 + | },
|
838 + | ],
|
839 + | )
|
840 + | .build()
|
841 + | .await;
|
842 + |
|
843 + | let client = make.make(ClientConfig::default());
|
844 + | let port = harness.endpoints[0].port();
|
845 + | let url = format!("http://127.0.0.1:{port}/");
|
846 + |
|
847 + | // First request — hold the response (body unconsumed, connection held)
|
848 + | let _held_resp = send_to(&client, &url).await.expect("first request");
|
849 + |
|
850 + | // Second request while first response is still held — must open new connection
|
851 + | let (status, body) = send_and_read_body(&client, &url)
|
852 + | .await
|
853 + | .expect("second request");
|
854 + | assert_eq!(status, 200);
|
855 + | assert_eq!(body, b"second");
|
856 + |
|
857 + | assert_eq!(
|
858 + | harness.tcp_accepted_count(),
|
859 + | 2,
|
860 + | "second request should open a new connection while first body is held"
|
861 + | );
|
862 + |
|
863 + | // Drop the held response — connection guard released
|
864 + | drop(_held_resp);
|
865 + | }
|
866 + |
|
867 + | /// Server sends Connection: close — client should not reuse the connection.
|
868 + | async fn connection_close_header_prevents_reuse(make: &dyn MakeClient) {
|
869 + | let harness = ConnectionTestHarness::builder()
|
870 + | .endpoint(
|
871 + | IP1,
|
872 + | vec![
|
873 + | ConnectionBehavior::RespondThenClose {
|
874 + | status: 200,
|
875 + | body: b"closing",
|
876 + | },
|
877 + | ConnectionBehavior::RespondKeepAlive {
|
878 + | status: 200,
|
879 + | body: b"fresh",
|
880 + | },
|
881 + | ],
|
882 + | )
|
883 + | .build()
|
884 + | .await;
|
885 + |
|
886 + | let client = make.make(ClientConfig::default());
|
887 + | let port = harness.endpoints[0].port();
|
888 + | let url = format!("http://127.0.0.1:{port}/");
|
889 + |
|
890 + | let (status, body) = send_and_read_body(&client, &url)
|
891 + | .await
|
892 + | .expect("first request");
|
893 + | assert_eq!(status, 200);
|
894 + | assert_eq!(body, b"closing");
|
895 + |
|
896 + | // Connection: close means the client should not attempt to reuse
|
897 + | let (status, body) = send_and_read_body(&client, &url)
|
898 + | .await
|
899 + | .expect("second request");
|
900 + | assert_eq!(status, 200);
|
901 + | assert_eq!(body, b"fresh");
|
902 + |
|
903 + | assert_eq!(
|
904 + | harness.tcp_accepted_count(),
|
905 + | 2,
|
906 + | "Connection: close should prevent reuse"
|
907 + | );
|
908 + | }
|
909 + |
|
910 + | #[tokio::test]
|
911 + | async fn v2_stale_connection_detected_at_checkout() {
|
912 + | stale_connection_detected_at_checkout(&V2Client).await;
|
913 + | }
|
914 + |
|
915 + | #[tokio::test]
|
916 + | async fn v2_unconsumed_body_holds_connection() {
|
917 + | unconsumed_body_holds_connection(&V2Client).await;
|
918 + | }
|
919 + |
|
920 + | #[tokio::test]
|
921 + | async fn v2_connection_close_header_prevents_reuse() {
|
922 + | connection_close_header_prevents_reuse(&V2Client).await;
|
923 + | }
|
924 + |
|
925 + | // ---------------------------------------------------------------------------
|
926 + | // Test implementations: connection poisoning
|
927 + | // ---------------------------------------------------------------------------
|
928 + |
|
929 + | /// Send a request and return both the response body and the `ConnectionMetadata`
|
930 + | /// captured by a `CaptureSmithyConnection` attached to the request. This is the
|
931 + | /// integration surface the `ConnectionPoisoningInterceptor` uses in real SDK flows
|
932 + | /// — the adapter must populate a retriever that returns live metadata pointing
|
933 + | /// at the connection selected for this request.
|
934 + | async fn send_with_capture(
|
935 + | client: &SharedHttpClient,
|
936 + | url: &str,
|
937 + | ) -> (
|
938 + | u16,
|
939 + | Vec<u8>,
|
940 + | Option<aws_smithy_runtime_api::client::connection::ConnectionMetadata>,
|
941 + | ) {
|
942 + | use aws_smithy_runtime_api::client::connection::CaptureSmithyConnection;
|
943 + | use http_body_util::BodyExt;
|
944 + |
|
945 + | let settings = HttpConnectorSettings::builder().build();
|
946 + | let components = runtime_components();
|
947 + | let connector = client.http_connector(&settings, &components);
|
948 + |
|
949 + | let capture = CaptureSmithyConnection::new();
|
950 + | let mut request = HttpRequest::get(url).expect("valid HTTP request");
|
951 + | request.add_extension(capture.clone());
|
952 + |
|
953 + | let resp = connector
|
954 + | .call(request)
|
955 + | .await
|
956 + | .expect("request should succeed");
|
957 + | let status = resp.status().as_u16();
|
958 + | let body = resp
|
959 + | .into_body()
|
960 + | .collect()
|
961 + | .await
|
962 + | .expect("body should be readable")
|
963 + | .to_bytes()
|
964 + | .to_vec();
|
965 + | (status, body, capture.get())
|
966 + | }
|
967 + |
|
968 + | /// Poisoning a connection prevents it from being reused.
|
969 + | ///
|
970 + | /// Mirrors the production flow: `ConnectionPoisoningInterceptor` attaches a
|
971 + | /// `CaptureSmithyConnection` to the request, the adapter populates it with
|
972 + | /// metadata pointing at the selected connection, and on a transient error
|
973 + | /// the interceptor calls `ConnectionMetadata::poison()`. The next request
|
974 + | /// for the same host must establish a new TCP connection instead of reusing
|
975 + | /// the poisoned one.
|
976 + | async fn poisoned_connection_not_reused(make: &dyn MakeClient) {
|
977 + | let harness = ConnectionTestHarness::builder()
|
978 + | .endpoint(
|
979 + | IP1,
|
980 + | vec![
|
981 + | ConnectionBehavior::RespondKeepAlive {
|
982 + | status: 200,
|
983 + | body: b"first",
|
984 + | },
|
985 + | ConnectionBehavior::RespondKeepAlive {
|
986 + | status: 200,
|
987 + | body: b"second",
|
988 + | },
|
989 + | ],
|
990 + | )
|
991 + | .build()
|
992 + | .await;
|
993 + |
|
994 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
995 + | let client = make.make(ClientConfig::default());
|
996 + |
|
997 + | // First request: establish connection, capture metadata.
|
998 + | let (status, body, metadata) = send_with_capture(&client, &url).await;
|
999 + | assert_eq!(status, 200);
|
1000 + | assert_eq!(body, b"first");
|
1001 + | let metadata = metadata.expect("adapter should populate CaptureSmithyConnection");
|
1002 + | assert_eq!(
|
1003 + | harness.tcp_accepted_count(),
|
1004 + | 1,
|
1005 + | "first request opens one connection"
|
1006 + | );
|
1007 + |
|
1008 + | // Poison the connection — what the orchestrator does on a transient error.
|
1009 + | metadata.poison();
|
1010 + |
|
1011 + | // Next request to the same host must open a NEW connection; the poisoned
|
1012 + | // one is skipped on checkout and dropped on return.
|
1013 + | let (status, body, _) = send_with_capture(&client, &url).await;
|
1014 + | assert_eq!(status, 200);
|
1015 + | assert_eq!(body, b"second");
|
1016 + | assert_eq!(
|
1017 + | harness.tcp_accepted_count(),
|
1018 + | 2,
|
1019 + | "poisoned connection must not be reused"
|
1020 + | );
|
1021 + | }
|
1022 + |
|
1023 + | /// When no transient error occurs, the captured metadata is handed out but
|
1024 + | /// never poisoned — the connection returns to the pool and is reused.
|
1025 + | /// This is the non-poison control for `poisoned_connection_not_reused`.
|
1026 + | async fn capture_without_poison_allows_reuse(make: &dyn MakeClient) {
|
1027 + | let harness = ConnectionTestHarness::builder()
|
1028 + | .endpoint(
|
1029 + | IP1,
|
1030 + | vec![
|
1031 + | ConnectionBehavior::RespondKeepAlive {
|
1032 + | status: 200,
|
1033 + | body: b"first",
|
1034 + | },
|
1035 + | ConnectionBehavior::RespondKeepAlive {
|
1036 + | status: 200,
|
1037 + | body: b"second",
|
1038 + | },
|
1039 + | ],
|
1040 + | )
|
1041 + | .build()
|
1042 + | .await;
|
1043 + |
|
1044 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1045 + | let client = make.make(ClientConfig::default());
|
1046 + |
|
1047 + | let (status, body, metadata) = send_with_capture(&client, &url).await;
|
1048 + | assert_eq!(status, 200);
|
1049 + | assert_eq!(body, b"first");
|
1050 + | assert!(
|
1051 + | metadata.is_some(),
|
1052 + | "adapter should populate CaptureSmithyConnection"
|
1053 + | );
|
1054 + | // Deliberately not calling poison().
|
1055 + | drop(metadata);
|
1056 + |
|
1057 + | let (status, body, _) = send_with_capture(&client, &url).await;
|
1058 + | assert_eq!(status, 200);
|
1059 + | assert_eq!(body, b"second");
|
1060 + | assert_eq!(
|
1061 + | harness.tcp_accepted_count(),
|
1062 + | 1,
|
1063 + | "without poison the connection should be reused"
|
1064 + | );
|
1065 + | }
|
1066 + |
|
1067 + | #[tokio::test]
|
1068 + | async fn v2_poisoned_connection_not_reused() {
|
1069 + | poisoned_connection_not_reused(&V2Client).await;
|
1070 + | }
|
1071 + |
|
1072 + | #[tokio::test]
|
1073 + | async fn v2_capture_without_poison_allows_reuse() {
|
1074 + | capture_without_poison_allows_reuse(&V2Client).await;
|
1075 + | }
|
1076 + |
|
1077 + | // ---------------------------------------------------------------------------
|
1078 + | // Test implementations: per-operation timeouts
|
1079 + | // ---------------------------------------------------------------------------
|
1080 + |
|
1081 + | /// Read timeout fires when the server accepts TCP but never sends a response.
|
1082 + | ///
|
1083 + | /// Exercises the v2 adapter's per-op timeout wrapping end-to-end: the
|
1084 + | /// `HttpConnectorSettings::read_timeout` flows through
|
1085 + | /// `HttpClient::http_connector` into `PooledConnector`, wraps
|
1086 + | /// `pool.send_request`, fires because `HoldThenClose` never replies, and
|
1087 + | /// produces a `ConnectorError::timeout` classified by `downcast_error`.
|
1088 + | async fn v2_read_timeout_fires_on_silent_server() {
|
1089 + | use aws_smithy_async::rt::sleep::{SharedAsyncSleep, TokioSleep};
|
1090 + |
|
1091 + | let harness = ConnectionTestHarness::builder()
|
1092 + | .endpoint(
|
1093 + | IP1,
|
1094 + | vec![ConnectionBehavior::HoldThenClose(Duration::from_secs(30))],
|
1095 + | )
|
1096 + | .build()
|
1097 + | .await;
|
1098 + |
|
1099 + | let client = V2Client.make(ClientConfig::default());
|
1100 + | let components =
|
1101 + | aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder::for_tests()
|
1102 + | .with_time_source(Some(SystemTimeSource::new()))
|
1103 + | .with_sleep_impl(Some(SharedAsyncSleep::new(TokioSleep::new())))
|
1104 + | .build()
|
1105 + | .expect("valid runtime components");
|
1106 + | let settings = HttpConnectorSettings::builder()
|
1107 + | .read_timeout(Duration::from_millis(200))
|
1108 + | .build();
|
1109 + | let connector = client.http_connector(&settings, &components);
|
1110 + |
|
1111 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1112 + | let start = std::time::Instant::now();
|
1113 + | let err = connector
|
1114 + | .call(HttpRequest::get(&url).expect("valid HTTP request"))
|
1115 + | .await
|
1116 + | .expect_err("read timeout should fire against a non-responsive server");
|
1117 + | let elapsed = start.elapsed();
|
1118 + |
|
1119 + | assert!(
|
1120 + | err.is_timeout(),
|
1121 + | "expected timeout classification, got {err:?}"
|
1122 + | );
|
1123 + | assert!(
|
1124 + | elapsed < Duration::from_secs(2),
|
1125 + | "read timeout did not fire in time (took {elapsed:?})"
|
1126 + | );
|
1127 + | }
|
1128 + |
|
1129 + | #[tokio::test]
|
1130 + | async fn v2_read_timeout() {
|
1131 + | v2_read_timeout_fires_on_silent_server().await;
|
1132 + | }
|
1133 + |
|
1134 + | // ---------------------------------------------------------------------------
|
1135 + | // Tracing output assertions
|
1136 + | // ---------------------------------------------------------------------------
|
1137 + | //
|
1138 + | // These tests verify the pool emits useful structured tracing events.
|
1139 + | // They use a thread-local subscriber and `current_thread` tokio so spawned
|
1140 + | // tasks (eviction) run on the same thread. Must be run with
|
1141 + | // `--test-threads=1` to avoid subscriber interference from parallel tests.
|
1142 + |
|
1143 + | fn capture_pool_logs() -> (
|
1144 + | tracing::subscriber::DefaultGuard,
|
1145 + | std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
|
1146 + | ) {
|
1147 + | use std::io::Write;
|
1148 + | use std::sync::{Arc, Mutex};
|
1149 + |
|
1150 + | struct BufWriter(Arc<Mutex<Vec<u8>>>);
|
1151 + | impl Write for BufWriter {
|
1152 + | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
1153 + | self.0.lock().unwrap().extend_from_slice(buf);
|
1154 + | Ok(buf.len())
|
1155 + | }
|
1156 + | fn flush(&mut self) -> std::io::Result<()> {
|
1157 + | Ok(())
|
1158 + | }
|
1159 + | }
|
1160 + |
|
1161 + | let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
|
1162 + | let buf_clone = buf.clone();
|
1163 + | let subscriber = tracing_subscriber::fmt()
|
1164 + | .with_ansi(false)
|
1165 + | .with_max_level(tracing::Level::TRACE)
|
1166 + | .with_writer(move || BufWriter(buf_clone.clone()))
|
1167 + | .finish();
|
1168 + | let guard = tracing::subscriber::set_default(subscriber);
|
1169 + | (guard, buf)
|
1170 + | }
|
1171 + |
|
1172 + | fn captured_str(buf: &std::sync::Arc<std::sync::Mutex<Vec<u8>>>) -> String {
|
1173 + | String::from_utf8(buf.lock().unwrap().clone()).expect("captured logs are utf-8")
|
1174 + | }
|
1175 + |
|
1176 + | /// Background eviction task emits structured tracing events including
|
1177 + | /// pool init, connection established, eviction with reason, and host
|
1178 + | /// entry removal.
|
1179 + | ///
|
1180 + | /// Requires `--test-threads=1` due to thread-local subscriber; run via:
|
1181 + | /// `cargo test --features default-client,test-util,wire-mock --test pool_behavior_test -- --ignored --test-threads=1`
|
1182 + | #[tokio::test(flavor = "current_thread")]
|
1183 + | #[serial_test::serial(tracing)]
|
1184 + | #[ignore]
|
1185 + | async fn v2_background_eviction_emits_tracing_events() {
|
1186 + | let (_guard, logs) = capture_pool_logs();
|
1187 + |
|
1188 + | let server = WireMockServer::start(vec![ReplayedEvent::status(200)]).await;
|
1189 + |
|
1190 + | let idle_timeout = Duration::from_millis(100);
|
1191 + | let client = V2Client.make(ClientConfig::default().with_idle_timeout(idle_timeout));
|
1192 + | let url = localhost_url(&server);
|
1193 + |
|
1194 + | let status = send_to(&client, &url)
|
1195 + | .await
|
1196 + | .expect("first request should succeed")
|
1197 + | .status()
|
1198 + | .as_u16();
|
1199 + | assert_eq!(status, 200);
|
1200 + |
|
1201 + | // Wait past idle timeout + eviction tick intervals.
|
1202 + | tokio::time::sleep(idle_timeout * 5).await;
|
1203 + |
|
1204 + | let captured = captured_str(&logs);
|
1205 + | assert!(
|
1206 + | captured.contains("pool: initialized"),
|
1207 + | "expected pool init log. captured:\n{captured}"
|
1208 + | );
|
1209 + | assert!(
|
1210 + | captured.contains("pool: eviction task spawned"),
|
1211 + | "expected eviction task spawn log. captured:\n{captured}"
|
1212 + | );
|
1213 + | assert!(
|
1214 + | captured.contains("pool: connection established"),
|
1215 + | "expected connection-established log. captured:\n{captured}"
|
1216 + | );
|
1217 + | assert!(
|
1218 + | captured.contains("pool: connection evicted"),
|
1219 + | "expected connection-evicted log. captured:\n{captured}"
|
1220 + | );
|
1221 + | assert!(
|
1222 + | captured.contains("pool: host entry removed"),
|
1223 + | "expected host-entry-removed log. captured:\n{captured}"
|
1224 + | );
|
1225 + | }
|
1226 + |
|
1227 + | /// conn_id is stable (same across a connection's lifetime) and monotonically
|
1228 + | /// increasing (new connection after eviction gets the next id).
|
1229 + | ///
|
1230 + | /// Requires `--test-threads=1`; see `v2_background_eviction_emits_tracing_events`.
|
1231 + | #[tokio::test(flavor = "current_thread")]
|
1232 + | #[serial_test::serial(tracing)]
|
1233 + | #[ignore]
|
1234 + | async fn v2_conn_id_is_stable_and_monotonic() {
|
1235 + | let (_guard, logs) = capture_pool_logs();
|
1236 + |
|
1237 + | let server =
|
1238 + | WireMockServer::start(vec![ReplayedEvent::status(200), ReplayedEvent::status(200)]).await;
|
1239 + |
|
1240 + | let idle_timeout = Duration::from_millis(100);
|
1241 + | let client = V2Client.make(ClientConfig::default().with_idle_timeout(idle_timeout));
|
1242 + | let url = localhost_url(&server);
|
1243 + |
|
1244 + | send_to(&client, &url).await.expect("first request");
|
1245 + | tokio::time::sleep(idle_timeout * 5).await;
|
1246 + | send_to(&client, &url).await.expect("second request");
|
1247 + |
|
1248 + | let captured = captured_str(&logs);
|
1249 + | assert!(
|
1250 + | captured.contains("conn_id=0"),
|
1251 + | "first connection should be conn_id=0. captured:\n{captured}"
|
1252 + | );
|
1253 + | assert!(
|
1254 + | captured.contains("conn_id=1"),
|
1255 + | "second connection (after eviction) should be conn_id=1. captured:\n{captured}"
|
1256 + | );
|
1257 + | }
|
1258 + |
|
1259 + | // ---------------------------------------------------------------------------
|
1260 + | // Connect timeout
|
1261 + | // ---------------------------------------------------------------------------
|
1262 + |
|
1263 + | /// Proves that `connect_timeout` from `HttpConnectorSettings` fires when the
|
1264 + | /// TCP connection cannot be established within the deadline. Uses TEST-NET-1
|
1265 + | /// (192.0.2.1), a non-routable address guaranteed to black-hole SYN packets.
|
1266 + | #[tokio::test]
|
1267 + | async fn v2_connect_timeout() {
|
1268 + | use aws_smithy_async::rt::sleep::{SharedAsyncSleep, TokioSleep};
|
1269 + |
|
1270 + | let client = V2Client.make(ClientConfig::default());
|
1271 + | let components =
|
1272 + | aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder::for_tests()
|
1273 + | .with_time_source(Some(SystemTimeSource::new()))
|
1274 + | .with_sleep_impl(Some(SharedAsyncSleep::new(TokioSleep::new())))
|
1275 + | .build()
|
1276 + | .expect("valid runtime components");
|
1277 + | let settings = HttpConnectorSettings::builder()
|
1278 + | .connect_timeout(Duration::from_millis(500))
|
1279 + | .build();
|
1280 + | let connector = client.http_connector(&settings, &components);
|
1281 + |
|
1282 + | let start = std::time::Instant::now();
|
1283 + | let err = connector
|
1284 + | .call(HttpRequest::get("http://192.0.2.1:1234/unreachable").expect("valid request"))
|
1285 + | .await
|
1286 + | .expect_err("connect timeout should fire against non-routable address");
|
1287 + | let elapsed = start.elapsed();
|
1288 + |
|
1289 + | assert!(
|
1290 + | err.is_timeout(),
|
1291 + | "expected timeout classification, got {err:?}"
|
1292 + | );
|
1293 + | assert!(
|
1294 + | elapsed >= Duration::from_millis(450),
|
1295 + | "timeout fired too early ({elapsed:?})"
|
1296 + | );
|
1297 + | assert!(
|
1298 + | elapsed < Duration::from_secs(5),
|
1299 + | "timeout took too long ({elapsed:?})"
|
1300 + | );
|
1301 + | }
|
1302 + |
|
1303 + | /// `ConnectionMetadata::connection_id()` surfaces the pool-assigned id
|
1304 + | /// through `CaptureSmithyConnection`. Sequential requests on the same
|
1305 + | /// connection share the same id; a new connection after eviction gets a
|
1306 + | /// different id.
|
1307 + | #[tokio::test]
|
1308 + | async fn v2_connection_id_surfaced_through_metadata() {
|
1309 + | use aws_smithy_runtime_api::client::connection::ConnectionId;
|
1310 + |
|
1311 + | let harness = ConnectionTestHarness::builder()
|
1312 + | .endpoint(
|
1313 + | IP1,
|
1314 + | vec![
|
1315 + | ConnectionBehavior::RespondKeepAlive {
|
1316 + | status: 200,
|
1317 + | body: b"a",
|
1318 + | },
|
1319 + | ConnectionBehavior::RespondKeepAlive {
|
1320 + | status: 200,
|
1321 + | body: b"b",
|
1322 + | },
|
1323 + | ConnectionBehavior::RespondKeepAlive {
|
1324 + | status: 200,
|
1325 + | body: b"c",
|
1326 + | },
|
1327 + | ],
|
1328 + | )
|
1329 + | .build()
|
1330 + | .await;
|
1331 + |
|
1332 + | let client = V2Client.make(ClientConfig {
|
1333 + | idle_timeout: Some(Duration::from_millis(80)),
|
1334 + | ..Default::default()
|
1335 + | });
|
1336 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1337 + |
|
1338 + | // First two requests reuse the same connection.
|
1339 + | let (_, _, meta1) = send_with_capture(&client, &url).await;
|
1340 + | let (_, _, meta2) = send_with_capture(&client, &url).await;
|
1341 + | let id1 = meta1
|
1342 + | .unwrap()
|
1343 + | .connection_id()
|
1344 + | .expect("v2 sets connection_id");
|
1345 + | let id2 = meta2
|
1346 + | .unwrap()
|
1347 + | .connection_id()
|
1348 + | .expect("v2 sets connection_id");
|
1349 + | assert_eq!(id1, id2, "same connection should have same id");
|
1350 + | assert_eq!(id1, ConnectionId::new(0));
|
1351 + |
|
1352 + | // Wait for eviction, then the next request gets a new connection.
|
1353 + | tokio::time::sleep(Duration::from_millis(200)).await;
|
1354 + |
|
1355 + | let (_, _, meta3) = send_with_capture(&client, &url).await;
|
1356 + | let id3 = meta3
|
1357 + | .unwrap()
|
1358 + | .connection_id()
|
1359 + | .expect("v2 sets connection_id");
|
1360 + | assert_ne!(
|
1361 + | id1, id3,
|
1362 + | "new connection after eviction should have different id"
|
1363 + | );
|
1364 + | assert_eq!(id3, ConnectionId::new(1));
|
1365 + | }
|
1366 + |
|
1367 + | // ---- ConnectionEventListener tests ----
|
1368 + |
|
1369 + | mod listener_tests {
|
1370 + | use super::*;
|
1371 + | use aws_smithy_http_client::pool::{
|
1372 + | CloseReason, ConnectionClosedEvent, ConnectionCreatedEvent, ConnectionEventListener,
|
1373 + | ConnectionFailedEvent, ConnectionReusedEvent,
|
1374 + | };
|
1375 + | use std::sync::{Arc, Mutex};
|
1376 + |
|
1377 + | /// Records connection lifecycle events for test assertions.
|
1378 + | #[derive(Debug, Clone, Default)]
|
1379 + | struct RecordingListener {
|
1380 + | /// (conn_id, authority) for each connection created
|
1381 + | created: Arc<Mutex<Vec<(u64, String)>>>,
|
1382 + | /// (conn_id, authority) for each connection reused from idle
|
1383 + | reused: Arc<Mutex<Vec<(u64, String)>>>,
|
1384 + | /// (conn_id, authority, reason) for each connection closed
|
1385 + | closed: Arc<Mutex<Vec<(u64, String, CloseReason)>>>,
|
1386 + | /// authority for each failed connection attempt
|
1387 + | failed: Arc<Mutex<Vec<String>>>,
|
1388 + | }
|
1389 + |
|
1390 + | impl ConnectionEventListener for RecordingListener {
|
1391 + | fn on_created(&self, event: &ConnectionCreatedEvent) {
|
1392 + | self.created.lock().unwrap().push((
|
1393 + | event.conn_id().to_string().parse().unwrap(),
|
1394 + | event.authority().as_str().to_string(),
|
1395 + | ));
|
1396 + | }
|
1397 + | fn on_reused(&self, event: &ConnectionReusedEvent) {
|
1398 + | self.reused.lock().unwrap().push((
|
1399 + | event.conn_id().to_string().parse().unwrap(),
|
1400 + | event.authority().as_str().to_string(),
|
1401 + | ));
|
1402 + | }
|
1403 + | fn on_closed(&self, event: &ConnectionClosedEvent) {
|
1404 + | self.closed.lock().unwrap().push((
|
1405 + | event.conn_id().to_string().parse().unwrap(),
|
1406 + | event.authority().as_str().to_string(),
|
1407 + | event.reason(),
|
1408 + | ));
|
1409 + | }
|
1410 + | fn on_connection_failed(&self, event: &ConnectionFailedEvent) {
|
1411 + | self.failed
|
1412 + | .lock()
|
1413 + | .unwrap()
|
1414 + | .push(event.authority().as_str().to_string());
|
1415 + | }
|
1416 + | }
|
1417 + |
|
1418 + | fn make_v2_with_listener(
|
1419 + | harness: &ConnectionTestHarness,
|
1420 + | idle_timeout: Option<Duration>,
|
1421 + | listener: Arc<dyn ConnectionEventListener>,
|
1422 + | ) -> SharedHttpClient {
|
1423 + | let mut builder = aws_smithy_http_client::pool::SharedPool::builder()
|
1424 + | .connection_event_listener(listener)
|
1425 + | .dns_resolver(harness.dns_resolver());
|
1426 + | if let Some(timeout) = idle_timeout {
|
1427 + | builder = builder.pool_idle_timeout(timeout);
|
1428 + | }
|
1429 + | let pool = builder.build_http();
|
1430 + | PoolClient::new(&pool).into_shared()
|
1431 + | }
|
1432 + |
|
1433 + | /// Listener receives created on first request, reused on second, and
|
1434 + | /// closed(IdleTimeout) after eviction.
|
1435 + | #[tokio::test]
|
1436 + | async fn listener_lifecycle_created_reused_closed() {
|
1437 + | let harness = ConnectionTestHarness::builder()
|
1438 + | .endpoint(
|
1439 + | IP1,
|
1440 + | vec![
|
1441 + | ConnectionBehavior::RespondKeepAlive {
|
1442 + | status: 200,
|
1443 + | body: b"a",
|
1444 + | },
|
1445 + | ConnectionBehavior::RespondKeepAlive {
|
1446 + | status: 200,
|
1447 + | body: b"b",
|
1448 + | },
|
1449 + | ],
|
1450 + | )
|
1451 + | .build()
|
1452 + | .await;
|
1453 + |
|
1454 + | let listener = Arc::new(RecordingListener::default());
|
1455 + | let client =
|
1456 + | make_v2_with_listener(&harness, Some(Duration::from_millis(80)), listener.clone());
|
1457 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1458 + |
|
1459 + | // First request: on_created
|
1460 + | send_and_read_body(&client, &url).await;
|
1461 + | assert_eq!(listener.created.lock().unwrap().len(), 1);
|
1462 + | assert_eq!(listener.created.lock().unwrap()[0].0, 0);
|
1463 + | assert!(listener.created.lock().unwrap()[0].1.contains("127.0.0.1"));
|
1464 + |
|
1465 + | // Second request: on_reused
|
1466 + | send_and_read_body(&client, &url).await;
|
1467 + | assert_eq!(listener.reused.lock().unwrap().len(), 1);
|
1468 + | assert_eq!(listener.reused.lock().unwrap()[0].0, 0);
|
1469 + |
|
1470 + | // Wait for eviction
|
1471 + | tokio::time::sleep(Duration::from_millis(200)).await;
|
1472 + |
|
1473 + | let closed = listener.closed.lock().unwrap();
|
1474 + | assert_eq!(closed.len(), 1);
|
1475 + | assert_eq!(closed[0].0, 0);
|
1476 + | assert_eq!(closed[0].2, CloseReason::IdleTimeout);
|
1477 + | }
|
1478 + |
|
1479 + | /// Poisoning a connection fires on_closed with Poisoned reason.
|
1480 + | #[tokio::test]
|
1481 + | async fn listener_poisoned_connection_fires_on_closed() {
|
1482 + | let harness = ConnectionTestHarness::builder()
|
1483 + | .endpoint(
|
1484 + | IP1,
|
1485 + | vec![
|
1486 + | ConnectionBehavior::RespondKeepAlive {
|
1487 + | status: 200,
|
1488 + | body: b"ok",
|
1489 + | },
|
1490 + | ConnectionBehavior::RespondKeepAlive {
|
1491 + | status: 200,
|
1492 + | body: b"ok",
|
1493 + | },
|
1494 + | ],
|
1495 + | )
|
1496 + | .build()
|
1497 + | .await;
|
1498 + |
|
1499 + | let listener = Arc::new(RecordingListener::default());
|
1500 + | let client = make_v2_with_listener(&harness, None, listener.clone());
|
1501 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1502 + |
|
1503 + | // Make a request and poison the connection
|
1504 + | let (_, _, meta) = super::send_with_capture(&client, &url).await;
|
1505 + | meta.unwrap().poison();
|
1506 + |
|
1507 + | // The poisoned connection is discarded on next checkout attempt
|
1508 + | send_and_read_body(&client, &url).await;
|
1509 + |
|
1510 + | let closed = listener.closed.lock().unwrap();
|
1511 + | assert_eq!(closed.len(), 1);
|
1512 + | assert_eq!(closed[0].2, CloseReason::Poisoned);
|
1513 + | }
|
1514 + |
|
1515 + | /// Connection failure fires on_connection_failed with the authority.
|
1516 + | #[tokio::test]
|
1517 + | async fn listener_connection_failed() {
|
1518 + | let listener = Arc::new(RecordingListener::default());
|
1519 + |
|
1520 + | // Use a harness with no endpoints so the connection will fail
|
1521 + | let harness = ConnectionTestHarness::builder()
|
1522 + | .endpoint(IP1, vec![ConnectionBehavior::ResetOnConnect])
|
1523 + | .build()
|
1524 + | .await;
|
1525 + |
|
1526 + | let client = make_v2_with_listener(&harness, None, listener.clone());
|
1527 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1528 + |
|
1529 + | let settings = HttpConnectorSettings::builder().build();
|
1530 + | let components = runtime_components();
|
1531 + | let connector = client.http_connector(&settings, &components);
|
1532 + | let request = HttpRequest::get(&url).expect("valid request");
|
1533 + | let _ = connector.call(request).await;
|
1534 + |
|
1535 + | let failed = listener.failed.lock().unwrap();
|
1536 + | assert_eq!(failed.len(), 1);
|
1537 + | assert!(failed[0].contains("127.0.0.1"));
|
1538 + | }
|
1539 + |
|
1540 + | /// Authority is correctly populated on events.
|
1541 + | #[tokio::test]
|
1542 + | async fn listener_authority_populated() {
|
1543 + | let harness = ConnectionTestHarness::builder()
|
1544 + | .endpoint(
|
1545 + | IP1,
|
1546 + | vec![ConnectionBehavior::RespondKeepAlive {
|
1547 + | status: 200,
|
1548 + | body: b"x",
|
1549 + | }],
|
1550 + | )
|
1551 + | .build()
|
1552 + | .await;
|
1553 + |
|
1554 + | let listener = Arc::new(RecordingListener::default());
|
1555 + | let client = make_v2_with_listener(&harness, None, listener.clone());
|
1556 + | let port = harness.endpoints[0].port();
|
1557 + | let url = format!("http://127.0.0.1:{}/path", port);
|
1558 + |
|
1559 + | send_and_read_body(&client, &url).await;
|
1560 + |
|
1561 + | let created = listener.created.lock().unwrap();
|
1562 + | assert_eq!(created[0].1, format!("127.0.0.1:{}", port));
|
1563 + | }
|
1564 + |
|
1565 + | /// A connection that the server closed while idle fires on_closed
|
1566 + | /// with Unusable reason when detected at checkout.
|
1567 + | #[tokio::test]
|
1568 + | async fn listener_unusable_connection_fires_on_closed() {
|
1569 + | let harness = ConnectionTestHarness::builder()
|
1570 + | .endpoint(
|
1571 + | IP1,
|
1572 + | vec![
|
1573 + | // First request succeeds, then server closes the connection.
|
1574 + | ConnectionBehavior::RespondThenClose {
|
1575 + | status: 200,
|
1576 + | body: b"ok",
|
1577 + | },
|
1578 + | // Second connection for the retry.
|
1579 + | ConnectionBehavior::RespondKeepAlive {
|
1580 + | status: 200,
|
1581 + | body: b"ok",
|
1582 + | },
|
1583 + | ],
|
1584 + | )
|
1585 + | .build()
|
1586 + | .await;
|
1587 + |
|
1588 + | let listener = Arc::new(RecordingListener::default());
|
1589 + | let client = make_v2_with_listener(&harness, None, listener.clone());
|
1590 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1591 + |
|
1592 + | // First request succeeds; connection returns to pool.
|
1593 + | send_and_read_body(&client, &url).await;
|
1594 + |
|
1595 + | // Brief pause for the server-side close to propagate.
|
1596 + | tokio::time::sleep(Duration::from_millis(50)).await;
|
1597 + |
|
1598 + | // Second request: pool checks out the dead connection, detects it
|
1599 + | // in poll_ready, fires on_closed(Unusable), then creates a new one.
|
1600 + | send_and_read_body(&client, &url).await;
|
1601 + |
|
1602 + | let closed = listener.closed.lock().unwrap();
|
1603 + | let unusable = closed.iter().find(|c| c.2 == CloseReason::Unusable);
|
1604 + | assert!(
|
1605 + | unusable.is_some(),
|
1606 + | "expected on_closed with Unusable reason, got: {:?}",
|
1607 + | *closed
|
1608 + | );
|
1609 + | assert_eq!(unusable.unwrap().0, 0, "should be the first connection");
|
1610 + | }
|
1611 + |
|
1612 + | /// ConnectionCreatedEvent carries non-zero connect_duration.
|
1613 + | #[tokio::test]
|
1614 + | async fn listener_timing_populated_on_created() {
|
1615 + | let timing: Arc<Mutex<Option<Duration>>> = Arc::new(Mutex::new(None));
|
1616 + |
|
1617 + | struct TimingListener(Arc<Mutex<Option<Duration>>>);
|
1618 + | impl ConnectionEventListener for TimingListener {
|
1619 + | fn on_created(&self, event: &ConnectionCreatedEvent) {
|
1620 + | *self.0.lock().unwrap() = Some(event.timing().connect_duration());
|
1621 + | }
|
1622 + | }
|
1623 + |
|
1624 + | let harness = ConnectionTestHarness::builder()
|
1625 + | .endpoint(
|
1626 + | IP1,
|
1627 + | vec![ConnectionBehavior::RespondKeepAlive {
|
1628 + | status: 200,
|
1629 + | body: b"ok",
|
1630 + | }],
|
1631 + | )
|
1632 + | .build()
|
1633 + | .await;
|
1634 + |
|
1635 + | let listener: Arc<dyn ConnectionEventListener> = Arc::new(TimingListener(timing.clone()));
|
1636 + | let client = make_v2_with_listener(&harness, None, listener);
|
1637 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1638 + |
|
1639 + | send_and_read_body(&client, &url).await;
|
1640 + |
|
1641 + | let duration = timing.lock().unwrap().expect("timing should be set");
|
1642 + | assert!(duration > Duration::ZERO, "connect_duration should be > 0");
|
1643 + | }
|
1644 + | }
|
1645 + |
|
1646 + | /// Prove that a partition's declared spawner is used to spawn the connection
|
1647 + | /// driver, not the free `TokioExecutor::new().execute(…)` helper that would
|
1648 + | /// target whatever runtime is current at connect time.
|
1649 + | #[tokio::test]
|
1650 + | async fn partition_spawners_are_isolated() {
|
1651 + | use aws_smithy_http_client::pool::{DriverSpawner, Partition, PartitionId, TokioDriverSpawner};
|
1652 + | use std::sync::atomic::{AtomicUsize, Ordering};
|
1653 + | use std::sync::Arc;
|
1654 + |
|
1655 + | /// A spawner that records how many times `spawn` was called, then
|
1656 + | /// delegates to an inner `TokioDriverSpawner` so the driver runs.
|
1657 + | #[derive(Debug)]
|
1658 + | struct RecordingSpawner {
|
1659 + | inner: TokioDriverSpawner,
|
1660 + | spawned: Arc<AtomicUsize>,
|
1661 + | }
|
1662 + |
|
1663 + | impl RecordingSpawner {
|
1664 + | fn new(spawned: Arc<AtomicUsize>) -> Self {
|
1665 + | Self {
|
1666 + | inner: TokioDriverSpawner::current(),
|
1667 + | spawned,
|
1668 + | }
|
1669 + | }
|
1670 + | }
|
1671 + |
|
1672 + | impl DriverSpawner for RecordingSpawner {
|
1673 + | fn spawn(
|
1674 + | &self,
|
1675 + | driver: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>,
|
1676 + | ) {
|
1677 + | self.spawned.fetch_add(1, Ordering::Relaxed);
|
1678 + | self.inner.spawn(driver);
|
1679 + | }
|
1680 + | }
|
1681 + |
|
1682 + | let harness = ConnectionTestHarness::builder()
|
1683 + | .endpoint(
|
1684 + | IP1,
|
1685 + | vec![
|
1686 + | ConnectionBehavior::RespondKeepAlive {
|
1687 + | status: 200,
|
1688 + | body: b"hello",
|
1689 + | },
|
1690 + | ConnectionBehavior::RespondKeepAlive {
|
1691 + | status: 200,
|
1692 + | body: b"hello",
|
1693 + | },
|
1694 + | ],
|
1695 + | )
|
1696 + | .build()
|
1697 + | .await;
|
1698 + |
|
1699 + | // Two partitions, each with its own recording spawner.
|
1700 + | let count0 = Arc::new(AtomicUsize::new(0));
|
1701 + | let count1 = Arc::new(AtomicUsize::new(0));
|
1702 + | let p0 = Partition::new(
|
1703 + | PartitionId::from_index(0),
|
1704 + | RecordingSpawner::new(count0.clone()),
|
1705 + | );
|
1706 + | let p1 = Partition::new(
|
1707 + | PartitionId::from_index(1),
|
1708 + | RecordingSpawner::new(count1.clone()),
|
1709 + | );
|
1710 + |
|
1711 + | let pool = SharedPool::builder()
|
1712 + | .dns_resolver(harness.dns_resolver())
|
1713 + | .partitions([p0, p1])
|
1714 + | .build_http();
|
1715 + |
|
1716 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1717 + |
|
1718 + | // A request on partition 0 must drive its connection through spawner 0
|
1719 + | // only — spawner 1 stays untouched (no cross-partition leakage).
|
1720 + | let client0 = PoolClient::from_partition(&pool, PartitionId::from_index(0)).into_shared();
|
1721 + | let (status, _) = send_and_read_body(&client0, &url)
|
1722 + | .await
|
1723 + | .expect("p0 request should succeed");
|
1724 + | assert_eq!(status, 200);
|
1725 + | assert!(
|
1726 + | count0.load(Ordering::Relaxed) >= 1,
|
1727 + | "partition 0's spawner should have driven its connection"
|
1728 + | );
|
1729 + | assert_eq!(
|
1730 + | count1.load(Ordering::Relaxed),
|
1731 + | 0,
|
1732 + | "partition 1's spawner must not be touched by a partition 0 request"
|
1733 + | );
|
1734 + |
|
1735 + | // A request on partition 1 drives through spawner 1; spawner 0's count
|
1736 + | // does not change (each partition opens its own connection).
|
1737 + | let count0_before = count0.load(Ordering::Relaxed);
|
1738 + | let client1 = PoolClient::from_partition(&pool, PartitionId::from_index(1)).into_shared();
|
1739 + | let (status, _) = send_and_read_body(&client1, &url)
|
1740 + | .await
|
1741 + | .expect("p1 request should succeed");
|
1742 + | assert_eq!(status, 200);
|
1743 + | assert!(
|
1744 + | count1.load(Ordering::Relaxed) >= 1,
|
1745 + | "partition 1's spawner should have driven its connection"
|
1746 + | );
|
1747 + | assert_eq!(
|
1748 + | count0.load(Ordering::Relaxed),
|
1749 + | count0_before,
|
1750 + | "partition 0's spawner must not be touched by a partition 1 request"
|
1751 + | );
|
1752 + | }
|
1753 + |
|
1754 + | // ---------------------------------------------------------------------------
|
1755 + | // Test implementations: cross-partition active reclaim (Never policy)
|
1756 + | // ---------------------------------------------------------------------------
|
1757 + |
|
1758 + | /// Under cap pressure, a starved partition reclaims an over-supplied peer's
|
1759 + | /// idle connection (freeing its permit) and connects locally — rather than
|
1760 + | /// waiting for passive idle eviction.
|
1761 + | ///
|
1762 + | /// Setup distinguishes *active* reclaim from passive eviction: the pool's
|
1763 + | /// idle timeout is set very long, so the eviction tick will not fire during
|
1764 + | /// the test. P1 establishes an idle connection holding the single global
|
1765 + | /// permit; P0 (same authority, same NIC group) then requests, finds the cap
|
1766 + | /// bound, reclaims P1's *non-expired* idle, and connects on a fresh
|
1767 + | /// connection. Proven via the `ConnectionEventListener`:
|
1768 + | /// - P1's connection closes with `CloseReason::Reclaimed` (not
|
1769 + | /// `IdleTimeout` — the tick never ran),
|
1770 + | /// - P0's connection is freshly created (distinct `conn_id`),
|
1771 + | /// - the server accepted two connections (P1's reclaimed, P0's new),
|
1772 + | /// - P0 completed well under the idle timeout.
|
1773 + | #[tokio::test]
|
1774 + | async fn v2_cross_partition_reclaim_frees_peer_idle() {
|
1775 + | use aws_smithy_http_client::pool::{
|
1776 + | CloseReason, ConnectionClosedEvent, ConnectionCreatedEvent, ConnectionEventListener,
|
1777 + | ConnectionFailedEvent, ConnectionReusedEvent, Partition, PartitionId, TokioDriverSpawner,
|
1778 + | };
|
1779 + | use std::sync::{Arc, Mutex};
|
1780 + |
|
1781 + | #[derive(Debug, Clone, Default)]
|
1782 + | struct RecordingListener {
|
1783 + | created: Arc<Mutex<Vec<(u64, String)>>>,
|
1784 + | closed: Arc<Mutex<Vec<(u64, String, CloseReason)>>>,
|
1785 + | }
|
1786 + | impl ConnectionEventListener for RecordingListener {
|
1787 + | fn on_created(&self, event: &ConnectionCreatedEvent) {
|
1788 + | self.created.lock().unwrap().push((
|
1789 + | event.conn_id().to_string().parse().unwrap(),
|
1790 + | event.authority().as_str().to_string(),
|
1791 + | ));
|
1792 + | }
|
1793 + | fn on_reused(&self, _event: &ConnectionReusedEvent) {}
|
1794 + | fn on_closed(&self, event: &ConnectionClosedEvent) {
|
1795 + | self.closed.lock().unwrap().push((
|
1796 + | event.conn_id().to_string().parse().unwrap(),
|
1797 + | event.authority().as_str().to_string(),
|
1798 + | event.reason(),
|
1799 + | ));
|
1800 + | }
|
1801 + | fn on_connection_failed(&self, _event: &ConnectionFailedEvent) {}
|
1802 + | }
|
1803 + |
|
1804 + | // One endpoint, two connections total: P1's (later reclaimed) and P0's.
|
1805 + | let harness = ConnectionTestHarness::builder()
|
1806 + | .endpoint(
|
1807 + | IP1,
|
1808 + | vec![
|
1809 + | ConnectionBehavior::RespondKeepAlive {
|
1810 + | status: 200,
|
1811 + | body: b"p1",
|
1812 + | },
|
1813 + | ConnectionBehavior::RespondKeepAlive {
|
1814 + | status: 200,
|
1815 + | body: b"p0",
|
1816 + | },
|
1817 + | ],
|
1818 + | )
|
1819 + | .build()
|
1820 + | .await;
|
1821 + |
|
1822 + | let listener = Arc::new(RecordingListener::default());
|
1823 + |
|
1824 + | // Two partitions sharing one NIC group (so they are reclaim peers),
|
1825 + | // global cap of 1 (so the second partition is cap-bound), and a long
|
1826 + | // idle timeout so passive eviction never fires during the test.
|
1827 + | let p0 = Partition::new(PartitionId::from_index(0), TokioDriverSpawner::current())
|
1828 + | .interface("eth-test");
|
1829 + | let p1 = Partition::new(PartitionId::from_index(1), TokioDriverSpawner::current())
|
1830 + | .interface("eth-test");
|
1831 + | let pool = SharedPool::builder()
|
1832 + | .dns_resolver(harness.dns_resolver())
|
1833 + | .connection_event_listener(listener.clone() as Arc<dyn ConnectionEventListener>)
|
1834 + | .max_connections(1)
|
1835 + | .pool_idle_timeout(Duration::from_secs(3600))
|
1836 + | .partitions([p0, p1])
|
1837 + | .build_http();
|
1838 + |
|
1839 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1840 + | let client0 = PoolClient::from_partition(&pool, PartitionId::from_index(0)).into_shared();
|
1841 + | let client1 = PoolClient::from_partition(&pool, PartitionId::from_index(1)).into_shared();
|
1842 + |
|
1843 + | // P1 establishes a connection and returns it idle to its cache, holding
|
1844 + | // the single global permit.
|
1845 + | let (_, _, meta1) = send_with_capture(&client1, &url).await;
|
1846 + | let p1_conn_id: u64 = meta1
|
1847 + | .expect("p1 metadata")
|
1848 + | .connection_id()
|
1849 + | .expect("p1 conn id")
|
1850 + | .to_string()
|
1851 + | .parse()
|
1852 + | .unwrap();
|
1853 + |
|
1854 + | // P0 requests the same authority. The global permit is held by P1's
|
1855 + | // idle connection, so P0 is cap-bound; it reclaims P1's idle inline,
|
1856 + | // freeing the permit, then connects locally. Bounded so a hang (failed
|
1857 + | // reclaim → indefinite block) surfaces as a test timeout rather than
|
1858 + | // a stall.
|
1859 + | let started = std::time::Instant::now();
|
1860 + | let (status, body, meta0) =
|
1861 + | tokio::time::timeout(Duration::from_secs(5), send_with_capture(&client0, &url))
|
1862 + | .await
|
1863 + | .expect("p0 must not block indefinitely — reclaim should free a permit");
|
1864 + | assert_eq!(status, 200);
|
1865 + | assert_eq!(body, b"p0");
|
1866 + | let p0_conn_id: u64 = meta0
|
1867 + | .expect("p0 metadata")
|
1868 + | .connection_id()
|
1869 + | .expect("p0 conn id")
|
1870 + | .to_string()
|
1871 + | .parse()
|
1872 + | .unwrap();
|
1873 + |
|
1874 + | // P0 completed promptly — far under the 3600s idle timeout, so this was
|
1875 + | // active reclaim, not passive eviction.
|
1876 + | assert!(
|
1877 + | started.elapsed() < Duration::from_secs(60),
|
1878 + | "p0 should complete promptly via reclaim, took {:?}",
|
1879 + | started.elapsed()
|
1880 + | );
|
1881 + |
|
1882 + | // P0 ran on a *fresh local* connection, not P1's (that is reclaim, not
|
1883 + | // borrow — borrow would reuse P1's exact connection).
|
1884 + | assert_ne!(
|
1885 + | p0_conn_id, p1_conn_id,
|
1886 + | "reclaim gives P0 its own fresh connection, distinct from P1's"
|
1887 + | );
|
1888 + |
|
1889 + | // The server accepted two connections: P1's (reclaimed) and P0's (new).
|
1890 + | assert_eq!(
|
1891 + | harness.tcp_accepted_count(),
|
1892 + | 2,
|
1893 + | "expected P1's connection + P0's fresh connection"
|
1894 + | );
|
1895 + |
|
1896 + | // The listener proves the mechanism directly: P1's connection closed
|
1897 + | // with `Reclaimed` (not `IdleTimeout` — the tick never ran), and P0's
|
1898 + | // connection was created fresh.
|
1899 + | let closed = listener.closed.lock().unwrap();
|
1900 + | let reclaimed: Vec<_> = closed
|
1901 + | .iter()
|
1902 + | .filter(|(_, _, reason)| matches!(reason, CloseReason::Reclaimed))
|
1903 + | .collect();
|
1904 + | assert_eq!(
|
1905 + | reclaimed.len(),
|
1906 + | 1,
|
1907 + | "exactly one connection should close with Reclaimed, got: {closed:?}"
|
1908 + | );
|
1909 + | assert_eq!(
|
1910 + | reclaimed[0].0, p1_conn_id,
|
1911 + | "the reclaimed connection must be P1's"
|
1912 + | );
|
1913 + | assert!(
|
1914 + | closed
|
1915 + | .iter()
|
1916 + | .all(|(_, _, reason)| !matches!(reason, CloseReason::IdleTimeout)),
|
1917 + | "no IdleTimeout close — the long idle timeout means reclaim, not passive eviction"
|
1918 + | );
|
1919 + |
|
1920 + | let created = listener.created.lock().unwrap();
|
1921 + | assert!(
|
1922 + | created.iter().any(|(id, _)| *id == p0_conn_id),
|
1923 + | "P0's fresh connection should fire on_created"
|
1924 + | );
|
1925 + | }
|
1926 + |
|
1927 + | // ---------------------------------------------------------------------------
|
1928 + | // Test implementations: cross-partition borrow (PreferLocal policy)
|
1929 + | // ---------------------------------------------------------------------------
|
1930 + |
|
1931 + | /// Under cap pressure with `PreferLocal`, a starved partition borrows a
|
1932 + | /// same-NIC peer's idle connection and dispatches its request through it —
|
1933 + | /// no new connection, no permit. This is the opposite disposition from
|
1934 + | /// reclaim: the connection stays the peer's (P0 runs on P1's exact
|
1935 + | /// connection), proven by `p0_conn_id == p1_conn_id` and a single TCP
|
1936 + | /// accept (P1's connection serves both requests via keep-alive).
|
1937 + | #[tokio::test]
|
1938 + | async fn v2_cross_partition_borrow_reuses_peer_connection() {
|
1939 + | use aws_smithy_http_client::pool::{
|
1940 + | CrossPartitionPolicy, Partition, PartitionId, TokioDriverSpawner,
|
1941 + | };
|
1942 + |
|
1943 + | // One endpoint, ONE connection serving TWO requests (P1's, then P0's
|
1944 + | // borrowed request via the keep-alive loop).
|
1945 + | let harness = ConnectionTestHarness::builder()
|
1946 + | .endpoint(
|
1947 + | IP1,
|
1948 + | vec![
|
1949 + | ConnectionBehavior::RespondKeepAlive {
|
1950 + | status: 200,
|
1951 + | body: b"p1",
|
1952 + | },
|
1953 + | ConnectionBehavior::RespondKeepAlive {
|
1954 + | status: 200,
|
1955 + | body: b"p0",
|
1956 + | },
|
1957 + | ],
|
1958 + | )
|
1959 + | .build()
|
1960 + | .await;
|
1961 + |
|
1962 + | // Two partitions, same NIC group (borrow peers), global cap 1, long
|
1963 + | // idle timeout so the eviction tick never interferes.
|
1964 + | let p0 = Partition::new(PartitionId::from_index(0), TokioDriverSpawner::current())
|
1965 + | .interface("eth-test");
|
1966 + | let p1 = Partition::new(PartitionId::from_index(1), TokioDriverSpawner::current())
|
1967 + | .interface("eth-test");
|
1968 + | let pool = SharedPool::builder()
|
1969 + | .dns_resolver(harness.dns_resolver())
|
1970 + | .cross_partition_policy(CrossPartitionPolicy::PreferLocal)
|
1971 + | .max_connections(1)
|
1972 + | .pool_idle_timeout(Duration::from_secs(3600))
|
1973 + | .partitions([p0, p1])
|
1974 + | .build_http();
|
1975 + |
|
1976 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
1977 + | let client0 = PoolClient::from_partition(&pool, PartitionId::from_index(0)).into_shared();
|
1978 + | let client1 = PoolClient::from_partition(&pool, PartitionId::from_index(1)).into_shared();
|
1979 + |
|
1980 + | // P1 establishes a connection, drains it, and returns it idle to its
|
1981 + | // cache holding the single global permit.
|
1982 + | let (_, _, meta1) = send_with_capture(&client1, &url).await;
|
1983 + | let p1_conn_id: u64 = meta1
|
1984 + | .expect("p1 metadata")
|
1985 + | .connection_id()
|
1986 + | .expect("p1 conn id")
|
1987 + | .to_string()
|
1988 + | .parse()
|
1989 + | .unwrap();
|
1990 + |
|
1991 + | // P0 requests the same authority. The permit is held by P1's idle
|
1992 + | // connection, so P0 is cap-bound; under PreferLocal it borrows P1's
|
1993 + | // connection and dispatches through it. Bounded so a hang surfaces as
|
1994 + | // a failure.
|
1995 + | let (status, body, meta0) =
|
1996 + | tokio::time::timeout(Duration::from_secs(5), send_with_capture(&client0, &url))
|
1997 + | .await
|
1998 + | .expect("p0 must not block — borrow should reuse P1's connection");
|
1999 + | assert_eq!(status, 200);
|
2000 + | assert_eq!(body, b"p0");
|
2001 + | let p0_conn_id: u64 = meta0
|
2002 + | .expect("p0 metadata")
|
2003 + | .connection_id()
|
2004 + | .expect("p0 conn id")
|
2005 + | .to_string()
|
2006 + | .parse()
|
2007 + | .unwrap();
|
2008 + |
|
2009 + | // The direct proof of borrow: P0 ran on P1's *exact* connection.
|
2010 + | assert_eq!(
|
2011 + | p0_conn_id, p1_conn_id,
|
2012 + | "PreferLocal borrow dispatches P0's request through P1's connection"
|
2013 + | );
|
2014 + |
|
2015 + | // One TCP accept total — P1's connection served both requests. A fresh
|
2016 + | // local connection for P0 (reclaim, or no borrow) would be 2.
|
2017 + | assert_eq!(
|
2018 + | harness.tcp_accepted_count(),
|
2019 + | 1,
|
2020 + | "borrow reuses the peer's connection; no new connection is opened"
|
2021 + | );
|
2022 + | }
|
2023 + |
|
2024 + | /// Storage residency under borrow: a borrowed connection never changes
|
2025 + | /// partitions. When P0 borrows P1's connection under `PreferLocal`, the
|
2026 + | /// connection stays resident in P1's storage — `stats()` shows P1 owning the
|
2027 + | /// one established connection and P0 owning zero, even though P0's request
|
2028 + | /// was served. Borrow dispatches *through* the peer's connection; it does
|
2029 + | /// not migrate it (connections never move). This is the wiring counterpart
|
2030 + | /// to the borrow test's `conn_id` equality: the id matches because the
|
2031 + | /// connection is P1's, and the residency confirms it stayed P1's.
|
2032 + | #[tokio::test]
|
2033 + | async fn v2_borrowed_connection_stays_resident_in_peer() {
|
2034 + | use aws_smithy_http_client::pool::{
|
2035 + | Authority, CrossPartitionPolicy, Partition, PartitionId, TokioDriverSpawner,
|
2036 + | };
|
2037 + |
|
2038 + | let harness = ConnectionTestHarness::builder()
|
2039 + | .endpoint(
|
2040 + | IP1,
|
2041 + | vec![
|
2042 + | ConnectionBehavior::RespondKeepAlive {
|
2043 + | status: 200,
|
2044 + | body: b"p1",
|
2045 + | },
|
2046 + | ConnectionBehavior::RespondKeepAlive {
|
2047 + | status: 200,
|
2048 + | body: b"p0",
|
2049 + | },
|
2050 + | ],
|
2051 + | )
|
2052 + | .build()
|
2053 + | .await;
|
2054 + |
|
2055 + | // Same setup as the borrow test: two same-NIC partitions, cap 1, long
|
2056 + | // idle timeout. The borrow is forced by P1 holding the only permit.
|
2057 + | let p0 = Partition::new(PartitionId::from_index(0), TokioDriverSpawner::current())
|
2058 + | .interface("eth-test");
|
2059 + | let p1 = Partition::new(PartitionId::from_index(1), TokioDriverSpawner::current())
|
2060 + | .interface("eth-test");
|
2061 + | let pool = SharedPool::builder()
|
2062 + | .dns_resolver(harness.dns_resolver())
|
2063 + | .cross_partition_policy(CrossPartitionPolicy::PreferLocal)
|
2064 + | .max_connections(1)
|
2065 + | .pool_idle_timeout(Duration::from_secs(3600))
|
2066 + | .partitions([p0, p1])
|
2067 + | .build_http();
|
2068 + |
|
2069 + | let port = harness.endpoints[0].port();
|
2070 + | let url = format!("http://127.0.0.1:{port}/");
|
2071 + | let authority = Authority::from_host(format!("127.0.0.1:{port}"));
|
2072 + | let client0 = PoolClient::from_partition(&pool, PartitionId::from_index(0)).into_shared();
|
2073 + | let client1 = PoolClient::from_partition(&pool, PartitionId::from_index(1)).into_shared();
|
2074 + |
|
2075 + | // P1 establishes and returns its connection idle (holding the permit).
|
2076 + | let _ = send_with_capture(&client1, &url).await;
|
2077 + |
|
2078 + | // P0 borrows P1's connection (cap-bound, PreferLocal) and its request
|
2079 + | // completes through it. send_with_capture drains the body, so all active
|
2080 + | // counts settle before we read stats.
|
2081 + | let (status, _, _) =
|
2082 + | tokio::time::timeout(Duration::from_secs(5), send_with_capture(&client0, &url))
|
2083 + | .await
|
2084 + | .expect("p0 must not block — borrow should reuse P1's connection");
|
2085 + | assert_eq!(status, 200);
|
2086 + |
|
2087 + | // Residency: the single established connection lives in P1's cell. P0
|
2088 + | // borrowed rather than created, so its cell holds zero established.
|
2089 + | let stats = pool.stats(&authority);
|
2090 + | let p1_stats = stats
|
2091 + | .get(PartitionId::from_index(1))
|
2092 + | .expect("P1 owns the established connection");
|
2093 + | assert_eq!(
|
2094 + | p1_stats.established, 1,
|
2095 + | "the connection is resident in P1's storage"
|
2096 + | );
|
2097 + | assert_eq!(
|
2098 + | stats
|
2099 + | .get(PartitionId::from_index(0))
|
2100 + | .map(|s| s.established)
|
2101 + | .unwrap_or(0),
|
2102 + | 0,
|
2103 + | "P0 borrowed P1's connection; it added nothing to P0's established"
|
2104 + | );
|
2105 + |
|
2106 + | // Exactly one connection exists across both partitions.
|
2107 + | let total_established: usize = stats.iter().map(|(_, s)| s.established).sum();
|
2108 + | assert_eq!(
|
2109 + | total_established, 1,
|
2110 + | "one connection total — borrow does not create or duplicate"
|
2111 + | );
|
2112 + | }
|
2113 + |
|
2114 + | /// Borrow is NIC-bounded: a peer on a different NIC is not a borrow
|
2115 + | /// candidate. With `PreferLocal` but P0 and P1 on different NICs, P0
|
2116 + | /// cannot borrow P1's connection — nor reclaim its permit (reclaim
|
2117 + | /// candidates are also drawn from the NIC group). P0's cap-bound wait is
|
2118 + | /// instead released when P1's idle connection is evicted (scenario B), and
|
2119 + | /// P0 then connects locally. Proven by `p0_conn_id != p1_conn_id` and two
|
2120 + | /// TCP accepts — the opposite of the same-NIC borrow case, which reuses
|
2121 + | /// P1's exact connection.
|
2122 + | #[tokio::test]
|
2123 + | async fn v2_cross_partition_borrow_respects_nic_boundary() {
|
2124 + | use aws_smithy_http_client::pool::{
|
2125 + | CrossPartitionPolicy, Partition, PartitionId, TokioDriverSpawner,
|
2126 + | };
|
2127 + |
|
2128 + | // One endpoint, two connections: P1's, then P0's fresh local one.
|
2129 + | let harness = ConnectionTestHarness::builder()
|
2130 + | .endpoint(
|
2131 + | IP1,
|
2132 + | vec![
|
2133 + | ConnectionBehavior::RespondKeepAlive {
|
2134 + | status: 200,
|
2135 + | body: b"p1",
|
2136 + | },
|
2137 + | ConnectionBehavior::RespondKeepAlive {
|
2138 + | status: 200,
|
2139 + | body: b"p0",
|
2140 + | },
|
2141 + | ],
|
2142 + | )
|
2143 + | .build()
|
2144 + | .await;
|
2145 + |
|
2146 + | // Two partitions on DIFFERENT NICs — not borrow peers (and not reclaim
|
2147 + | // peers). A short idle timeout lets P0's cap-bound wait be released by
|
2148 + | // eviction of P1's idle connection, rather than hanging.
|
2149 + | let p0 = Partition::new(PartitionId::from_index(0), TokioDriverSpawner::current())
|
2150 + | .interface("eth-zero");
|
2151 + | let p1 = Partition::new(PartitionId::from_index(1), TokioDriverSpawner::current())
|
2152 + | .interface("eth-one");
|
2153 + | let pool = SharedPool::builder()
|
2154 + | .dns_resolver(harness.dns_resolver())
|
2155 + | .cross_partition_policy(CrossPartitionPolicy::PreferLocal)
|
2156 + | .max_connections(1)
|
2157 + | .pool_idle_timeout(Duration::from_millis(150))
|
2158 + | .partitions([p0, p1])
|
2159 + | .build_http();
|
2160 + |
|
2161 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
2162 + | let client0 = PoolClient::from_partition(&pool, PartitionId::from_index(0)).into_shared();
|
2163 + | let client1 = PoolClient::from_partition(&pool, PartitionId::from_index(1)).into_shared();
|
2164 + |
|
2165 + | let (_, _, meta1) = send_with_capture(&client1, &url).await;
|
2166 + | let p1_conn_id: u64 = meta1
|
2167 + | .expect("p1 metadata")
|
2168 + | .connection_id()
|
2169 + | .expect("p1 conn id")
|
2170 + | .to_string()
|
2171 + | .parse()
|
2172 + | .unwrap();
|
2173 + |
|
2174 + | // P0 is cap-bound and cannot borrow across the NIC boundary; it waits
|
2175 + | // for P1's idle to be evicted, then connects locally. Bounded so a true
|
2176 + | // hang still surfaces as a failure.
|
2177 + | let (status, body, meta0) =
|
2178 + | tokio::time::timeout(Duration::from_secs(5), send_with_capture(&client0, &url))
|
2179 + | .await
|
2180 + | .expect("p0 should proceed once P1's idle is evicted");
|
2181 + | assert_eq!(status, 200);
|
2182 + | assert_eq!(body, b"p0");
|
2183 + | let p0_conn_id: u64 = meta0
|
2184 + | .expect("p0 metadata")
|
2185 + | .connection_id()
|
2186 + | .expect("p0 conn id")
|
2187 + | .to_string()
|
2188 + | .parse()
|
2189 + | .unwrap();
|
2190 + |
|
2191 + | // P1 is on a different NIC → not a borrow candidate. P0 does NOT run on
|
2192 + | // P1's connection; it gets its own.
|
2193 + | assert_ne!(
|
2194 + | p0_conn_id, p1_conn_id,
|
2195 + | "no borrow across NICs — P0 runs on its own connection"
|
2196 + | );
|
2197 + |
|
2198 + | // Two TCP accepts: P1's connection plus P0's fresh local one.
|
2199 + | assert_eq!(
|
2200 + | harness.tcp_accepted_count(),
|
2201 + | 2,
|
2202 + | "NIC boundary blocks borrow; P0 opens its own connection"
|
2203 + | );
|
2204 + | }
|
2205 + |
|
2206 + | // ---------------------------------------------------------------------------
|
2207 + | // Test implementations: cross-partition concurrency stress (TSan target)
|
2208 + | // ---------------------------------------------------------------------------
|
2209 + |
|
2210 + | /// Drives concurrent cross-partition borrow, reclaim, and eviction against
|
2211 + | /// a shared authority under a binding cap. The assertion is intentionally
|
2212 + | /// weak (every request completes); the value is the interleaving:
|
2213 + | /// concurrent `try_borrow_on` / `try_reclaim_on` touch a peer's
|
2214 + | /// `authorities` and cache locks while that peer serves its own requests
|
2215 + | /// and the eviction task runs `retain` on the same caches. `additional-ci`
|
2216 + | /// additionally runs it under ThreadSanitizer.
|
2217 + | #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
2218 + | async fn v2_cross_partition_concurrency_stress() {
|
2219 + | use aws_smithy_http_client::pool::{
|
2220 + | CrossPartitionPolicy, Partition, PartitionId, TokioDriverSpawner,
|
2221 + | };
|
2222 + |
|
2223 + | const PARTITIONS: usize = 4;
|
2224 + | const ROUNDS: usize = 8;
|
2225 + | const REQUESTS_PER_ROUND: usize = 16;
|
2226 + |
|
2227 + | // Single loopback endpoint serving many keep-alive requests. The
|
2228 + | // cross-partition contention comes from multiple partitions sharing
|
2229 + | // one authority under a binding cap, not from IP spread.
|
2230 + | let harness = ConnectionTestHarness::builder()
|
2231 + | .endpoint(
|
2232 + | IP1,
|
2233 + | (0..512)
|
2234 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
2235 + | status: 200,
|
2236 + | body: b"ok",
|
2237 + | })
|
2238 + | .collect(),
|
2239 + | )
|
2240 + | .build()
|
2241 + | .await;
|
2242 + |
|
2243 + | // No NIC binding (the common case): all partitions land in the single
|
2244 + | // implicit NIC group, so they are borrow + reclaim peers. A small
|
2245 + | // global cap forces cross-partition contention; a short idle timeout
|
2246 + | // makes the eviction task churn concurrently with borrow/reclaim.
|
2247 + | let parts = (0..PARTITIONS)
|
2248 + | .map(|i| Partition::new(PartitionId::from_index(i), TokioDriverSpawner::current()));
|
2249 + | let pool = SharedPool::builder()
|
2250 + | .dns_resolver(harness.dns_resolver())
|
2251 + | .cross_partition_policy(CrossPartitionPolicy::PreferLocal)
|
2252 + | .max_connections(PARTITIONS) // bind the cap below the offered load
|
2253 + | .pool_idle_timeout(Duration::from_millis(20))
|
2254 + | .partitions(parts)
|
2255 + | .build_http();
|
2256 + |
|
2257 + | let clients: Vec<SharedHttpClient> = (0..PARTITIONS)
|
2258 + | .map(|i| PoolClient::from_partition(&pool, PartitionId::from_index(i)).into_shared())
|
2259 + | .collect();
|
2260 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
2261 + |
|
2262 + | for _ in 0..ROUNDS {
|
2263 + | let mut tasks = tokio::task::JoinSet::new();
|
2264 + | for r in 0..REQUESTS_PER_ROUND {
|
2265 + | // Spread requests across partitions so borrow/reclaim peers
|
2266 + | // are all live at once.
|
2267 + | let client = clients[r % PARTITIONS].clone();
|
2268 + | let url = url.clone();
|
2269 + | tasks.spawn(async move { send_and_read_body(&client, &url).await });
|
2270 + | }
|
2271 + | while let Some(result) = tasks.join_next().await {
|
2272 + | let (status, _) = result
|
2273 + | .expect("task should not panic")
|
2274 + | .expect("request should succeed under cross-partition contention");
|
2275 + | assert_eq!(status, 200);
|
2276 + | }
|
2277 + | // Let the eviction tick fire between rounds so the next round
|
2278 + | // races fresh connects against reclaim/borrow on partly-evicted
|
2279 + | // caches.
|
2280 + | tokio::time::sleep(Duration::from_millis(30)).await;
|
2281 + | }
|
2282 + | }
|
2283 + |
|
2284 + | // ---------------------------------------------------------------------------
|
2285 + | // Test implementations: stats read API
|
2286 + | // ---------------------------------------------------------------------------
|
2287 + |
|
2288 + | /// SharedPool::stats returns sparse per-partition snapshots: only partitions
|
2289 + | /// that have touched an authority appear. After a request completes and the
|
2290 + | /// body is consumed, counters reflect the idle state.
|
2291 + | #[tokio::test]
|
2292 + | async fn v2_stats_reports_per_partition_sparse() {
|
2293 + | use aws_smithy_http_client::pool::{Authority, Partition, PartitionId, TokioDriverSpawner};
|
2294 + |
|
2295 + | let harness = ConnectionTestHarness::builder()
|
2296 + | .endpoint(
|
2297 + | IP1,
|
2298 + | vec![ConnectionBehavior::RespondKeepAlive {
|
2299 + | status: 200,
|
2300 + | body: b"stats-test",
|
2301 + | }],
|
2302 + | )
|
2303 + | .build()
|
2304 + | .await;
|
2305 + |
|
2306 + | let p0 = Partition::new(PartitionId::from_index(0), TokioDriverSpawner::current());
|
2307 + | let p1 = Partition::new(PartitionId::from_index(1), TokioDriverSpawner::current());
|
2308 + |
|
2309 + | let pool = SharedPool::builder()
|
2310 + | .dns_resolver(harness.dns_resolver())
|
2311 + | .partitions([p0, p1])
|
2312 + | .build_http();
|
2313 + |
|
2314 + | let port = harness.endpoints[0].port();
|
2315 + | let url = format!("http://127.0.0.1:{port}/");
|
2316 + | let authority = Authority::from_host(format!("127.0.0.1:{port}"));
|
2317 + |
|
2318 + | // Before any request, stats are empty for this authority.
|
2319 + | let stats = pool.stats(&authority);
|
2320 + | assert!(
|
2321 + | stats.is_empty(),
|
2322 + | "no partition should have touched this authority yet"
|
2323 + | );
|
2324 + |
|
2325 + | // Send a request on partition 0, consume the body so the connection idles.
|
2326 + | let client0 = PoolClient::from_partition(&pool, PartitionId::from_index(0)).into_shared();
|
2327 + | let (status, body) = send_and_read_body(&client0, &url)
|
2328 + | .await
|
2329 + | .expect("p0 request should succeed");
|
2330 + | assert_eq!(status, 200);
|
2331 + | assert_eq!(body, b"stats-test");
|
2332 + |
|
2333 + | // After the request idles, partition 0 should appear, partition 1 should not.
|
2334 + | let stats = pool.stats(&authority);
|
2335 + | assert_eq!(stats.len(), 1, "only partition 0 should appear (sparse)");
|
2336 + |
|
2337 + | let p0_stats = stats
|
2338 + | .get(PartitionId::from_index(0))
|
2339 + | .expect("partition 0 should have stats");
|
2340 + | assert_eq!(p0_stats.established, 1, "one connection established");
|
2341 + | assert_eq!(p0_stats.establishing, 0, "no handshakes in flight");
|
2342 + | assert_eq!(p0_stats.active, 0, "connection is idle after body consumed");
|
2343 + | assert_eq!(p0_stats.idle(), 1, "one idle connection");
|
2344 + | // H1 cell: capacity_hint is Some(idle)
|
2345 + | assert_eq!(p0_stats.capacity_hint(), Some(1));
|
2346 + |
|
2347 + | assert!(
|
2348 + | stats.get(PartitionId::from_index(1)).is_none(),
|
2349 + | "partition 1 has not touched this authority"
|
2350 + | );
|
2351 + | }
|
2352 + |
|
2353 + | /// `active` tracks an in-flight request end-to-end. The H1 checkout guard
|
2354 + | /// rides the response body (`GuardedBody`): it is held while the response
|
2355 + | /// value is alive and releases when the body is consumed/dropped, returning
|
2356 + | /// the connection to the pool. So `active == 1` is observable for as long as
|
2357 + | /// the caller holds the response, and drops to 0 once the body is drained.
|
2358 + | #[tokio::test]
|
2359 + | async fn v2_stats_active_tracks_in_flight_request() {
|
2360 + | use aws_smithy_http_client::pool::{Authority, PartitionId};
|
2361 + | use http_body_util::BodyExt;
|
2362 + |
|
2363 + | let harness = ConnectionTestHarness::builder()
|
2364 + | .endpoint(
|
2365 + | IP1,
|
2366 + | vec![ConnectionBehavior::RespondKeepAlive {
|
2367 + | status: 200,
|
2368 + | body: b"in-flight",
|
2369 + | }],
|
2370 + | )
|
2371 + | .build()
|
2372 + | .await;
|
2373 + |
|
2374 + | let pool = SharedPool::builder()
|
2375 + | .dns_resolver(harness.dns_resolver())
|
2376 + | .build_http();
|
2377 + |
|
2378 + | let port = harness.endpoints[0].port();
|
2379 + | let url = format!("http://127.0.0.1:{port}/");
|
2380 + | let authority = Authority::from_host(format!("127.0.0.1:{port}"));
|
2381 + | let partition = PartitionId::default();
|
2382 + |
|
2383 + | let client = PoolClient::new(&pool).into_shared();
|
2384 + |
|
2385 + | // Issue the request but hold the response without draining the body. The
|
2386 + | // connection is checked out: its guard is alive inside `resp`'s body.
|
2387 + | let resp = send_to(&client, &url)
|
2388 + | .await
|
2389 + | .expect("request should succeed");
|
2390 + | assert_eq!(resp.status().as_u16(), 200);
|
2391 + |
|
2392 + | let stats = pool.stats(&authority);
|
2393 + | let in_flight = stats
|
2394 + | .get(partition)
|
2395 + | .expect("partition should have touched this authority");
|
2396 + | assert_eq!(in_flight.established, 1, "one connection established");
|
2397 + | assert_eq!(
|
2398 + | in_flight.active, 1,
|
2399 + | "request is in flight, connection checked out"
|
2400 + | );
|
2401 + | assert_eq!(in_flight.idle(), 0, "no idle connection while in flight");
|
2402 + |
|
2403 + | // Drain the body: the GuardedBody drops, CachedConnection::Drop fires,
|
2404 + | // active decrements and the connection returns to the pool as idle.
|
2405 + | let body = resp
|
2406 + | .into_body()
|
2407 + | .collect()
|
2408 + | .await
|
2409 + | .expect("body should be readable")
|
2410 + | .to_bytes()
|
2411 + | .to_vec();
|
2412 + | assert_eq!(body, b"in-flight");
|
2413 + |
|
2414 + | let stats = pool.stats(&authority);
|
2415 + | let idle = stats
|
2416 + | .get(partition)
|
2417 + | .expect("partition still present after request completes");
|
2418 + | assert_eq!(idle.established, 1, "connection still established (idle)");
|
2419 + | assert_eq!(idle.active, 0, "no in-flight request after body drained");
|
2420 + | assert_eq!(idle.idle(), 1, "connection is idle and reusable");
|
2421 + | }
|
2422 + |
|
2423 + | /// Eviction decrements `established` and prunes the stats-index cell through
|
2424 + | /// its real trigger — the background eviction task — not a direct prune call.
|
2425 + | /// After an idle connection is evicted, the host entry is removed and the
|
2426 + | /// eviction-triggered prune drops the now-dead cell from the index, so
|
2427 + | /// `stats()` reports the authority as untracked.
|
2428 + | #[tokio::test]
|
2429 + | async fn v2_stats_pruned_after_eviction() {
|
2430 + | use aws_smithy_http_client::pool::{Authority, PartitionId};
|
2431 + |
|
2432 + | let idle_timeout = Duration::from_millis(100);
|
2433 + |
|
2434 + | let harness = ConnectionTestHarness::builder()
|
2435 + | .endpoint(
|
2436 + | IP1,
|
2437 + | vec![
|
2438 + | ConnectionBehavior::RespondKeepAlive {
|
2439 + | status: 200,
|
2440 + | body: b"evict-me",
|
2441 + | },
|
2442 + | // A second connection is available if eviction forces a reconnect;
|
2443 + | // the test asserts on stats, not connection count.
|
2444 + | ConnectionBehavior::RespondKeepAlive {
|
2445 + | status: 200,
|
2446 + | body: b"evict-me",
|
2447 + | },
|
2448 + | ],
|
2449 + | )
|
2450 + | .build()
|
2451 + | .await;
|
2452 + |
|
2453 + | let pool = SharedPool::builder()
|
2454 + | .dns_resolver(harness.dns_resolver())
|
2455 + | .pool_idle_timeout(idle_timeout)
|
2456 + | .build_http();
|
2457 + |
|
2458 + | let port = harness.endpoints[0].port();
|
2459 + | let url = format!("http://127.0.0.1:{port}/");
|
2460 + | let authority = Authority::from_host(format!("127.0.0.1:{port}"));
|
2461 + |
|
2462 + | let client = PoolClient::new(&pool).into_shared();
|
2463 + |
|
2464 + | // Request completes and the connection idles. This also lazily spawns the
|
2465 + | // eviction task (pool_idle_timeout is set).
|
2466 + | let (status, _) = send_and_read_body(&client, &url)
|
2467 + | .await
|
2468 + | .expect("request should succeed");
|
2469 + | assert_eq!(status, 200);
|
2470 + |
|
2471 + | let stats = pool.stats(&authority);
|
2472 + | assert_eq!(
|
2473 + | stats
|
2474 + | .get(PartitionId::default())
|
2475 + | .expect("partition present after request")
|
2476 + | .established,
|
2477 + | 1,
|
2478 + | "one established idle connection before eviction"
|
2479 + | );
|
2480 + |
|
2481 + | // Wait past the idle timeout: the eviction task drops the idle connection,
|
2482 + | // removes the host entry, and prunes the now-dead index cell.
|
2483 + | tokio::time::sleep(idle_timeout * 3).await;
|
2484 + |
|
2485 + | let stats = pool.stats(&authority);
|
2486 + | assert!(
|
2487 + | stats.is_empty(),
|
2488 + | "eviction should have decremented established and pruned the cell"
|
2489 + | );
|
2490 + | }
|
2491 + |
|
2492 + | // ---------------------------------------------------------------------------
|
2493 + | // Test implementations: at-the-limit scenarios
|
2494 + | // ---------------------------------------------------------------------------
|
2495 + |
|
2496 + | /// A request blocked at the connection cap proceeds once an in-flight
|
2497 + | /// request releases its permit. With `max_connections(1)`, the first
|
2498 + | /// request pins the only permit by holding its response; a second request
|
2499 + | /// cannot acquire and stays pending until the first is released, then
|
2500 + | /// completes.
|
2501 + | #[tokio::test]
|
2502 + | async fn v2_cap_bound_request_waits_then_proceeds() {
|
2503 + | let harness = ConnectionTestHarness::builder()
|
2504 + | .endpoint(
|
2505 + | IP1,
|
2506 + | (0..2)
|
2507 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
2508 + | status: 200,
|
2509 + | body: b"ok",
|
2510 + | })
|
2511 + | .collect(),
|
2512 + | )
|
2513 + | .build()
|
2514 + | .await;
|
2515 + |
|
2516 + | let pool = SharedPool::builder()
|
2517 + | .dns_resolver(harness.dns_resolver())
|
2518 + | .max_connections(1)
|
2519 + | .build_http();
|
2520 + | let client = PoolClient::new(&pool).into_shared();
|
2521 + | let url = format!("http://127.0.0.1:{}/", harness.endpoints[0].port());
|
2522 + |
|
2523 + | // First request holds the only permit: the response is kept, body
|
2524 + | // undrained, so the connection stays checked out.
|
2525 + | let held = send_to(&client, &url)
|
2526 + | .await
|
2527 + | .expect("first request succeeds");
|
2528 + | assert_eq!(held.status().as_u16(), 200);
|
2529 + |
|
2530 + | // Second request cannot acquire a permit; it must not complete while the
|
2531 + | // first is held.
|
2532 + | let mut second = tokio::spawn({
|
2533 + | let client = client.clone();
|
2534 + | let url = url.clone();
|
2535 + | async move { send_and_read_body(&client, &url).await }
|
2536 + | });
|
2537 + | assert!(
|
2538 + | tokio::time::timeout(Duration::from_millis(200), &mut second)
|
2539 + | .await
|
2540 + | .is_err(),
|
2541 + | "second request must stay pending while the cap is held"
|
2542 + | );
|
2543 + |
|
2544 + | // Release the permit by dropping the first response (its body guard
|
2545 + | // drops, returning the connection to the pool).
|
2546 + | drop(held);
|
2547 + |
|
2548 + | // The second request now acquires the freed permit and completes.
|
2549 + | let (status, body) = tokio::time::timeout(Duration::from_secs(5), second)
|
2550 + | .await
|
2551 + | .expect("second request completes after permit release")
|
2552 + | .expect("spawned task does not panic")
|
2553 + | .expect("second request succeeds");
|
2554 + | assert_eq!(status, 200);
|
2555 + | assert_eq!(body, b"ok");
|
2556 + |
|
2557 + | // At most two connections: dropping the first response with its body
|
2558 + | // undrained closes that H1 connection (an unconsumed body cannot be
|
2559 + | // reused), so the second request may open a fresh one. The scenario
|
2560 + | // under test is the permit wait-then-proceed, not connection reuse.
|
2561 + | assert!(harness.tcp_accepted_count() <= 2);
|
2562 + | }
|
2563 + |
|
2564 + | /// A host saturated at its per-host cap does not block requests to a
|
2565 + | /// different host. With `max_connections_per_host(1)` and global headroom,
|
2566 + | /// holding host X's only permit leaves a request to X pending while a
|
2567 + | /// request to host Y proceeds — the per-host-before-global acquire order.
|
2568 + | #[tokio::test]
|
2569 + | async fn v2_per_host_cap_isolates_hosts() {
|
2570 + | // Two distinct authorities, same loopback endpoint (the per-host cap is
|
2571 + | // keyed by authority, so distinct hostnames are distinct hosts even on
|
2572 + | // one listener).
|
2573 + | let harness = ConnectionTestHarness::builder()
|
2574 + | .endpoint(
|
2575 + | IP1,
|
2576 + | (0..4)
|
2577 + | .map(|_| ConnectionBehavior::RespondKeepAlive {
|
2578 + | status: 200,
|
2579 + | body: b"ok",
|
2580 + | })
|
2581 + | .collect(),
|
2582 + | )
|
2583 + | .dns("host-x.test", vec![IP1])
|
2584 + | .dns("host-y.test", vec![IP1])
|
2585 + | .build()
|
2586 + | .await;
|
2587 + |
|
2588 + | let pool = SharedPool::builder()
|
2589 + | .dns_resolver(harness.dns_resolver())
|
2590 + | .max_connections_per_host(1)
|
2591 + | .build_http();
|
2592 + | let client = PoolClient::new(&pool).into_shared();
|
2593 + | let port = harness.endpoints[0].port();
|
2594 + | let url_x = format!("http://host-x.test:{port}/");
|
2595 + | let url_y = format!("http://host-y.test:{port}/");
|
2596 + |
|
2597 + | // Pin host X at its per-host cap by holding the response.
|
2598 + | let held_x = send_to(&client, &url_x).await.expect("x request succeeds");
|
2599 + | assert_eq!(held_x.status().as_u16(), 200);
|
2600 + |
|
2601 + | // A second request to X is blocked on X's per-host permit.
|
2602 + | let mut x2 = tokio::spawn({
|
2603 + | let client = client.clone();
|
2604 + | let url_x = url_x.clone();
|
2605 + | async move { send_and_read_body(&client, &url_x).await }
|
2606 + | });
|
2607 + | assert!(
|
2608 + | tokio::time::timeout(Duration::from_millis(200), &mut x2)
|
2609 + | .await
|
2610 + | .is_err(),
|
2611 + | "second X request must wait on X's saturated per-host cap"
|
2612 + | );
|
2613 + |
|
2614 + | // A request to host Y proceeds: Y's per-host cap is independent and
|
2615 + | // global has headroom.
|
2616 + | let (status, body) =
|
2617 + | tokio::time::timeout(Duration::from_secs(5), send_and_read_body(&client, &url_y))
|
2618 + | .await
|
2619 + | .expect("Y request must not be blocked by X's saturation")
|
2620 + | .expect("y request succeeds");
|
2621 + | assert_eq!(status, 200);
|
2622 + | assert_eq!(body, b"ok");
|
2623 + |
|
2624 + | // Release X and let its waiter finish so the spawned task is not leaked.
|
2625 + | drop(held_x);
|
2626 + | let _ = tokio::time::timeout(Duration::from_secs(5), x2)
|
2627 + | .await
|
2628 + | .expect("x2 completes after release")
|
2629 + | .expect("x2 task does not panic")
|
2630 + | .expect("x2 succeeds");
|
2631 + | }
|