Skip to content

Commit f475b7d

Browse files
authored
Stream EdgeZero asset responses instead of buffering them (#825)
1 parent 0ceb45a commit f475b7d

3 files changed

Lines changed: 146 additions & 65 deletions

File tree

crates/trusted-server-adapter-fastly/src/app.rs

Lines changed: 100 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@
6565
//! run on these responses. Legacy ran EC finalization on its own auth
6666
//! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC
6767
//! cookies are issued to unauthenticated callers.
68-
//! - **Streaming publisher responses** are buffered (bounded by
68+
//! - **Publisher responses** are buffered (bounded by
6969
//! `publisher.max_buffered_body_bytes`) instead of streamed to the client.
70+
//! Asset responses are streamed straight to the client (see
71+
//! [`dispatch_asset_fallback`]), matching legacy.
7072
//! - **Router-level 405s** (unregistered verbs) skip EC finalization along
7173
//! with the middleware chain; the entry point still adds TS headers.
7274
//!
@@ -110,11 +112,10 @@ use trusted_server_core::integrations::{
110112
use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices};
111113
use trusted_server_core::proxy::{
112114
handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy,
113-
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, stream_asset_body,
114-
AssetProxyCachePolicy,
115+
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy,
115116
};
116117
use trusted_server_core::publisher::{
117-
buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, BoundedWriter,
118+
buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic,
118119
};
119120
use trusted_server_core::request_signing::{
120121
handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery,
@@ -734,13 +735,13 @@ async fn dispatch_fallback(
734735
attach_dispatch_extensions(response, ec, effects)
735736
}
736737

737-
/// Returns `true` when an asset response should carry a buffered body and a
738-
/// recomputed `Content-Length`.
738+
/// Returns `true` when an asset response should carry a (streamed) body.
739739
///
740740
/// `HEAD` responses and bodiless statuses (204, 304) advertise the origin
741741
/// representation length in their `Content-Length` header while carrying no
742-
/// body. Rewriting that header to the buffered byte count (0) would corrupt the
743-
/// metadata, so those responses keep the origin's `Content-Length` untouched.
742+
/// body. Attaching the origin stream would either contradict that header or
743+
/// stream bytes a client does not expect, so those responses drop the stream and
744+
/// keep the origin's `Content-Length` untouched.
744745
fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool {
745746
*method != Method::HEAD
746747
&& status != StatusCode::NO_CONTENT
@@ -756,13 +757,13 @@ fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool {
756757
/// is intentionally skipped: no [`EcFinalizeState`] is attached, matching the
757758
/// legacy `should_finalize_ec = false` behavior for asset responses.
758759
///
759-
/// Unlike legacy `route_request`, which streams asset bodies straight to the
760-
/// client with no cap, the `EdgeZero` path buffers them: `edgezero_main`
761-
/// converts the whole response before sending, so there is no streaming seam
762-
/// yet. The buffer is bounded by `publisher.max_buffered_body_bytes` as an
763-
/// interim Wasm-heap OOM guard. Reusing the publisher cap and restoring
764-
/// uncapped streaming are both resolved by the streaming cutover (issue #495);
765-
/// whether assets get a dedicated cap is deferred to that work.
760+
/// Like legacy `route_request`, asset bodies are streamed straight to the client
761+
/// with no cap: the origin stream is attached to the response and `edgezero_main`
762+
/// commits the headers, then pipes the body chunk-by-chunk via
763+
/// [`fastly::Response::stream_to_client`] and
764+
/// [`stream_asset_body`](trusted_server_core::proxy::stream_asset_body). The
765+
/// origin stream is left untouched here, so large images never materialize in
766+
/// the Wasm heap.
766767
async fn dispatch_asset_fallback(
767768
state: &AppState,
768769
services: &RuntimeServices,
@@ -779,30 +780,13 @@ async fn dispatch_asset_fallback(
779780
let cache_policy = asset_response.cache_policy();
780781
let (mut response, stream_body) = asset_response.into_response_and_body();
781782

783+
// Attach the origin stream so `edgezero_main` streams it to the
784+
// client. HEAD and bodiless statuses (204, 304) advertise the origin
785+
// Content-Length but carry no body, so their stream is dropped to
786+
// preserve that header and avoid a length/body mismatch.
782787
if let Some(body) = stream_body {
783-
match buffer_asset_body(body, state.settings.publisher.max_buffered_body_bytes)
784-
.await
785-
{
786-
Ok(bytes) => {
787-
// Preserve the origin's Content-Length for HEAD and
788-
// bodiless statuses; only body-bearing responses get a
789-
// recomputed length and the buffered body attached.
790-
if asset_response_carries_body(&method, response.status()) {
791-
response.headers_mut().insert(
792-
header::CONTENT_LENGTH,
793-
HeaderValue::from(bytes.len() as u64),
794-
);
795-
*response.body_mut() = edgezero_core::body::Body::from(bytes);
796-
}
797-
}
798-
Err(report) => {
799-
let mut response = http_error(&report);
800-
response
801-
.extensions_mut()
802-
.insert(AssetProxyCachePolicy::NoStorePrivate);
803-
attach_request_filter_effects(&mut response, effects);
804-
return response;
805-
}
788+
if asset_response_carries_body(&method, response.status()) {
789+
*response.body_mut() = body;
806790
}
807791
}
808792

@@ -833,23 +817,6 @@ fn attach_request_filter_effects(response: &mut Response, effects: &RequestFilte
833817
}
834818
}
835819

836-
/// Buffers a streaming asset body into memory, bounded by `max_bytes`
837-
/// (the interim `publisher.max_buffered_body_bytes` OOM guard; see
838-
/// [`dispatch_asset_fallback`]).
839-
///
840-
/// # Errors
841-
///
842-
/// Returns an error if the body exceeds the configured cap or the underlying
843-
/// stream yields an error.
844-
async fn buffer_asset_body(
845-
body: edgezero_core::body::Body,
846-
max_bytes: usize,
847-
) -> Result<Vec<u8>, Report<TrustedServerError>> {
848-
let mut output = BoundedWriter::new(max_bytes);
849-
stream_asset_body(body, &mut output).await?;
850-
Ok(output.into_inner())
851-
}
852-
853820
// ---------------------------------------------------------------------------
854821
// Error helper
855822
// ---------------------------------------------------------------------------
@@ -1141,6 +1108,10 @@ mod tests {
11411108
build_state_from_settings, startup_error_router, AppState, NamedRouteHandler,
11421109
TrustedServerApp, NAMED_ROUTES,
11431110
};
1111+
use crate::route_tests::{
1112+
test_runtime_services_with_secret_http_client_and_geo, us_california_geo, FixedBackend,
1113+
FixedGeo, NoopSecretStore, StreamingRecordingHttpClient,
1114+
};
11441115

11451116
use edgezero_core::body::Body;
11461117
use edgezero_core::http::{header, request_builder, Method, StatusCode};
@@ -1158,7 +1129,7 @@ mod tests {
11581129
HeaderMutation, IntegrationRegistry, IntegrationRequestFilter, RequestFilterDecision,
11591130
RequestFilterEffects, RequestFilterInput,
11601131
};
1161-
use trusted_server_core::platform::ClientInfo;
1132+
use trusted_server_core::platform::{ClientInfo, PlatformHttpClient};
11621133
use trusted_server_core::settings::Settings;
11631134

11641135
fn settings_with_missing_consent_store() -> Settings {
@@ -2036,6 +2007,79 @@ mod tests {
20362007
);
20372008
}
20382009

2010+
#[test]
2011+
fn dispatch_asset_fallback_streams_origin_body_without_buffering() {
2012+
// Regression guard for the EdgeZero asset streaming cutover: a successful
2013+
// asset proxy must hand `edgezero_main` a streaming body (`Body::Stream`)
2014+
// rather than draining the origin into a buffered `Body::Once`. Injecting a
2015+
// streaming HTTP client lets us assert the body type the entry point will
2016+
// pipe straight to the client via `stream_to_client`.
2017+
let settings = Settings::from_toml(
2018+
r#"
2019+
[[handlers]]
2020+
path = "^/_ts/admin"
2021+
username = "admin"
2022+
password = "admin-pass"
2023+
2024+
[publisher]
2025+
domain = "test-publisher.com"
2026+
cookie_domain = ".test-publisher.com"
2027+
origin_url = "https://origin.test-publisher.com"
2028+
proxy_secret = "unit-test-proxy-secret"
2029+
2030+
[ec]
2031+
passphrase = "test-secret-key-32-bytes-minimum"
2032+
2033+
[request_signing]
2034+
enabled = false
2035+
config_store_id = "test-config-store-id"
2036+
secret_store_id = "test-secret-store-id"
2037+
2038+
[proxy]
2039+
2040+
[[proxy.asset_routes]]
2041+
prefix = "/.images/"
2042+
origin_url = "https://assets.example.com"
2043+
"#,
2044+
)
2045+
.expect("should parse asset-route settings");
2046+
let state = build_state_from_settings(settings).expect("should build state");
2047+
2048+
let fastly_req = fastly::Request::get("https://test-publisher.com/.images/logo.png");
2049+
let http_client = Arc::new(StreamingRecordingHttpClient::new());
2050+
let services = test_runtime_services_with_secret_http_client_and_geo(
2051+
&fastly_req,
2052+
Arc::new(FixedBackend),
2053+
Arc::new(NoopSecretStore),
2054+
Arc::clone(&http_client) as Arc<dyn PlatformHttpClient>,
2055+
Arc::new(FixedGeo(us_california_geo())),
2056+
);
2057+
let req = crate::compat::from_fastly_request(fastly_req);
2058+
let asset_route = state
2059+
.settings
2060+
.asset_route_for_path("/.images/logo.png")
2061+
.expect("should match the configured asset route");
2062+
let effects = RequestFilterEffects::default();
2063+
2064+
let response = block_on(super::dispatch_asset_fallback(
2065+
&state,
2066+
&services,
2067+
req,
2068+
asset_route,
2069+
&effects,
2070+
));
2071+
2072+
assert_eq!(
2073+
response.status(),
2074+
StatusCode::OK,
2075+
"should preserve the streaming origin status"
2076+
);
2077+
assert!(
2078+
matches!(response.body(), Body::Stream(_)),
2079+
"EdgeZero asset dispatch must stream the origin body, not buffer it"
2080+
);
2081+
}
2082+
20392083
#[test]
20402084
fn dispatch_runs_request_filter_and_threads_response_effects() {
20412085
// Regression guard for the EdgeZero request-filter bypass: the publisher

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) {
535535
if let Some(effects) = &request_filter_effects {
536536
effects.apply_to_response(&mut response);
537537
}
538-
compat::to_fastly_response(response).send_to_client();
538+
send_core_response(response);
539539

540540
if ec_state.is_real_browser {
541541
if let Some(context) = build_pull_sync_context(&ec_state.ec_context) {
@@ -564,7 +564,44 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) {
564564
if let Some(effects) = &request_filter_effects {
565565
effects.apply_to_response(&mut response);
566566
}
567-
compat::to_fastly_response(response).send_to_client();
567+
send_core_response(response);
568+
}
569+
570+
/// Sends a finalized `EdgeZero` response to the client.
571+
///
572+
/// Asset responses carry an [`EdgeBody::Stream`] body: the headers are committed
573+
/// first via [`fastly::Response::stream_to_client`], then the origin body is
574+
/// piped chunk-by-chunk with [`stream_asset_body`] so large images never
575+
/// materialize in the Wasm heap. This mirrors the legacy
576+
/// `HandlerOutcome::AssetStreaming` path. All other responses carry a buffered
577+
/// [`EdgeBody::Once`] body and are sent in a single shot.
578+
fn send_core_response(response: HttpResponse) {
579+
let (parts, body) = response.into_parts();
580+
match body {
581+
EdgeBody::Stream(_) => {
582+
let skeleton = compat::to_fastly_response_skeleton(HttpResponse::from_parts(
583+
parts,
584+
EdgeBody::empty(),
585+
));
586+
let mut streaming_body = skeleton.stream_to_client();
587+
match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) {
588+
Ok(()) => {
589+
if let Err(e) = streaming_body.finish() {
590+
log::error!("failed to finish EdgeZero asset streaming body: {e}");
591+
}
592+
}
593+
Err(e) => {
594+
log::error!("EdgeZero asset streaming failed: {e:?}");
595+
// Headers already committed; drop the body so the client sees
596+
// a truncated response (EOF mid-stream), standard proxy behavior.
597+
drop(streaming_body);
598+
}
599+
}
600+
}
601+
once => {
602+
compat::to_fastly_response(HttpResponse::from_parts(parts, once)).send_to_client();
603+
}
604+
}
568605
}
569606

570607
fn take_finalize_sentinel(response: &mut HttpResponse) -> bool {

crates/trusted-server-adapter-fastly/src/route_tests.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl PlatformConfigStore for StubJwksConfigStore {
6363
}
6464
}
6565

66-
struct NoopSecretStore;
66+
pub(crate) struct NoopSecretStore;
6767

6868
struct HashMapSecretStore {
6969
data: HashMap<String, Vec<u8>>,
@@ -145,7 +145,7 @@ struct RecordingHttpClient {
145145
response_body: Vec<u8>,
146146
}
147147

148-
struct StreamingRecordingHttpClient {
148+
pub(crate) struct StreamingRecordingHttpClient {
149149
calls: Mutex<Vec<RecordedHttpCall>>,
150150
}
151151

@@ -177,7 +177,7 @@ impl RecordingHttpClient {
177177
}
178178

179179
impl StreamingRecordingHttpClient {
180-
fn new() -> Self {
180+
pub(crate) fn new() -> Self {
181181
Self {
182182
calls: Mutex::new(Vec::new()),
183183
}
@@ -191,7 +191,7 @@ struct RecordedHttpCall {
191191
stream_response: bool,
192192
}
193193

194-
struct FixedBackend;
194+
pub(crate) struct FixedBackend;
195195

196196
impl PlatformBackend for FixedBackend {
197197
fn predict_name(&self, spec: &PlatformBackendSpec) -> Result<String, Report<PlatformError>> {
@@ -355,15 +355,15 @@ impl AuctionProvider for DisabledRouteProvider {
355355
}
356356
}
357357

358-
struct FixedGeo(GeoInfo);
358+
pub(crate) struct FixedGeo(pub(crate) GeoInfo);
359359

360360
impl PlatformGeo for FixedGeo {
361361
fn lookup(&self, _client_ip: Option<IpAddr>) -> Result<Option<GeoInfo>, Report<PlatformError>> {
362362
Ok(Some(self.0.clone()))
363363
}
364364
}
365365

366-
fn us_california_geo() -> GeoInfo {
366+
pub(crate) fn us_california_geo() -> GeoInfo {
367367
GeoInfo {
368368
city: "Example City".to_string(),
369369
country: "US".to_string(),
@@ -532,7 +532,7 @@ fn test_runtime_services_with_secret_and_http_client(
532532
)
533533
}
534534

535-
fn test_runtime_services_with_secret_http_client_and_geo(
535+
pub(crate) fn test_runtime_services_with_secret_http_client_and_geo(
536536
req: &Request,
537537
backend: Arc<dyn PlatformBackend>,
538538
secret_store: Arc<dyn PlatformSecretStore>,

0 commit comments

Comments
 (0)