Skip to content

Commit 48dd1ad

Browse files
committed
Preserve each HTTP hop's absolute connection deadline
1 parent 227696f commit 48dd1ad

3 files changed

Lines changed: 602 additions & 26 deletions

File tree

‎build.rs‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -732,10 +732,7 @@ fn parse_namespace_manifest(path: &Path) -> Vec<NamespaceDecl> {
732732
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
733733
let mut decls = Vec::new();
734734
let mut rest = source.as_str();
735-
loop {
736-
let Some(index) = rest.find("builtin_namespace!(") else {
737-
break;
738-
};
735+
while let Some(index) = rest.find("builtin_namespace!(") {
739736
rest = &rest[index + "builtin_namespace!(".len()..];
740737
let end = find_matching_paren(rest);
741738
let args = &rest[..end];

‎src/builtins/runtime/http/request.rs‎

Lines changed: 82 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ use hyper::body::{Body as _, Bytes, Frame, Incoming};
99
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
1010
use hyper_util::client::legacy::Client;
1111
use hyper_util::client::legacy::connect::HttpConnector;
12-
use hyper_util::rt::TokioExecutor;
1312
use tower_service::Service;
1413

1514
use super::config::HttpConfig;
@@ -22,10 +21,37 @@ const HTTP_MAX_HEADERS: usize = 100;
2221

2322
type BoxConnectError = Box<dyn Error + Send + Sync>;
2423

24+
tokio::task_local! {
25+
/// Immutable per-hop budget, including policy preflight. Never keyed by host.
26+
static CONNECT_DEADLINE: Instant;
27+
}
28+
29+
/// Hyper can move a started connection race into its executor when pool checkout
30+
/// wins (hyper-util 0.1.20, legacy/client.rs). Capture at submission, not polling:
31+
/// connector readiness/call may then run on another task. Dispatcher tasks carry
32+
/// context only; response headers and bodies are not timed by this deadline.
33+
#[derive(Clone, Copy)]
34+
struct HttpExecutor;
35+
36+
impl<F> hyper::rt::Executor<F> for HttpExecutor
37+
where
38+
F: Future + Send + 'static,
39+
F::Output: Send + 'static,
40+
{
41+
fn execute(&self, future: F) {
42+
let deadline = CONNECT_DEADLINE.try_with(|deadline| *deadline).ok();
43+
tokio::spawn(async move {
44+
match deadline {
45+
Some(deadline) => CONNECT_DEADLINE.scope(deadline, future).await,
46+
None => future.await,
47+
}
48+
});
49+
}
50+
}
51+
2552
#[derive(Clone)]
2653
pub(super) struct ConnectTimeout<C> {
2754
inner: C,
28-
timeout: std::time::Duration,
2955
}
3056

3157
impl<C> Service<hyper::Uri> for ConnectTimeout<C>
@@ -44,10 +70,19 @@ where
4470
}
4571

4672
fn call(&mut self, destination: hyper::Uri) -> Self::Future {
73+
let Ok(deadline) = CONNECT_DEADLINE.try_with(|deadline| *deadline) else {
74+
// Missing propagation must fail closed, never restart a full budget.
75+
return Box::pin(async {
76+
Err(BoxConnectError::from(std::io::Error::other(
77+
"HTTP connect phase deadline context missing",
78+
)))
79+
});
80+
};
4781
let future = self.inner.call(destination);
48-
let timeout = self.timeout;
82+
#[cfg(test)]
83+
let future = deadline_tests::delay_connect(future);
4984
Box::pin(async move {
50-
tokio::time::timeout(timeout, future)
85+
tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), future)
5186
.await
5287
.map_err(|_| {
5388
BoxConnectError::from(std::io::Error::new(
@@ -75,11 +110,8 @@ pub(super) fn build_client(config: &HttpConfig) -> HttpClient {
75110
.https_or_http()
76111
.enable_http1()
77112
.wrap_connector(http);
78-
let connector = ConnectTimeout {
79-
inner: connector,
80-
timeout: config.connect_timeout,
81-
};
82-
let mut builder = Client::builder(TokioExecutor::new());
113+
let connector = ConnectTimeout { inner: connector };
114+
let mut builder = Client::builder(HttpExecutor);
83115
builder
84116
.http1_max_buf_size(HTTP_MAX_HEAD_BYTES)
85117
.http1_max_headers(HTTP_MAX_HEADERS);
@@ -91,7 +123,12 @@ async fn validate_target_until(
91123
url: &url::Url,
92124
deadline: Instant,
93125
) -> VmResult<()> {
94-
let resolved = with_deadline(deadline, resolve_url(config, SchemeFamily::Http, url)).await?;
126+
let resolved = with_deadline(deadline, async {
127+
#[cfg(test)]
128+
deadline_tests::delay_preflight().await;
129+
resolve_url(config, SchemeFamily::Http, url).await
130+
})
131+
.await?;
95132
debug_assert_eq!(resolved.host, url.host_str().unwrap_or_default());
96133
debug_assert_eq!(
97134
resolved.address.port(),
@@ -312,7 +349,15 @@ async fn execute_request_until(
312349
// policy resolver repeats the private-address check for the address
313350
// used by any newly opened pooled connection.
314351
validate_target_until(config, &url, connect_deadline).await?;
315-
let mut response = send_request(client, &method, &url, &headers, body.as_deref()).await?;
352+
let mut response = send_request(
353+
client,
354+
&method,
355+
&url,
356+
&headers,
357+
body.as_deref(),
358+
connect_deadline,
359+
)
360+
.await?;
316361
validate_response_framing(response.response())?;
317362
if follows_location(response.response().status()) {
318363
if redirect_index == config.max_redirects {
@@ -428,7 +473,15 @@ pub(super) async fn open_stream_response(
428473
for redirect_index in 0..=config.max_redirects {
429474
let connect_deadline = phase_deadline(opening_deadline, config.connect_timeout);
430475
validate_target_until(config, &url, connect_deadline).await?;
431-
let response = send_request(client, &method, &url, &headers, body.as_deref()).await?;
476+
let response = send_request(
477+
client,
478+
&method,
479+
&url,
480+
&headers,
481+
body.as_deref(),
482+
connect_deadline,
483+
)
484+
.await?;
432485
*bytes_sent = bytes_sent.saturating_add(body.as_ref().map_or(0, Vec::len));
433486
validate_response_framing(response.response())?;
434487
if follows_location(response.response().status()) {
@@ -473,6 +526,7 @@ async fn send_request(
473526
url: &url::Url,
474527
headers: &[(hyper::header::HeaderName, hyper::header::HeaderValue)],
475528
body: Option<&[u8]>,
529+
connect_deadline: Instant,
476530
) -> VmResult<OwnedResponse> {
477531
let uri = url
478532
.as_str()
@@ -485,16 +539,19 @@ async fn send_request(
485539
let request = builder
486540
.body(Full::new(Bytes::copy_from_slice(body.unwrap_or_default())))
487541
.map_err(|error| VmError::HostError(format!("HTTP request setup failed: {error}")))?;
488-
let response = client.request(request).await.map_err(|error| {
489-
let mut message = format!("HTTP request failed: {error}");
490-
let mut source = error.source();
491-
while let Some(error) = source {
492-
message.push_str(": ");
493-
message.push_str(&error.to_string());
494-
source = error.source();
495-
}
496-
VmError::HostError(message)
497-
})?;
542+
let response = CONNECT_DEADLINE
543+
.scope(connect_deadline, client.request(request))
544+
.await
545+
.map_err(|error| {
546+
let mut message = format!("HTTP request failed: {error}");
547+
let mut source = error.source();
548+
while let Some(error) = source {
549+
message.push_str(": ");
550+
message.push_str(&error.to_string());
551+
source = error.source();
552+
}
553+
VmError::HostError(message)
554+
})?;
498555
Ok(OwnedResponse { response })
499556
}
500557

@@ -712,6 +769,9 @@ fn reject_declared_oversize(
712769
Ok(())
713770
}
714771

772+
#[cfg(test)]
773+
mod deadline_tests;
774+
715775
#[cfg(test)]
716776
mod tests {
717777
use super::{RequestHeaderBudget, validate_resource_request, validate_response_trailers};

0 commit comments

Comments
 (0)