Phase 4: Stream binary pass-through responses via io::copy#594
Phase 4: Stream binary pass-through responses via io::copy#594aram356 wants to merge 6 commits intofeature/streaming-pipeline-phase3from
Conversation
Dismissing to resubmit with complete review body — inline comments and body were split across two API calls due to tooling issue.
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Phase 4 is mechanically correct: the PassThrough variant cleanly separates binary pass-through from streaming/buffered paths, Content-Length is preserved via send_to_client(), the 204 exclusion is properly handled, and the once_cell → LazyLock migration reduces the dependency surface. Two items need to be addressed before merge.
Blocking
🔧 wrench
- Stale PR title: The title says "via
io::copy" but the final implementation usesresponse.set_body(body)+send_to_client(). Commit3aa88fereplaced theio::copyapproach specifically to preserveContent-Length. The title should read something like "Phase 4: Pass-through binary responses with Content-Length preservation viasend_to_client".
Non-blocking
🤔 thinking
- Empty-host + non-processable 2xx behavioral change is undocumented: Before Phase 4, empty-host requests always went
Buffered. Now non-processable 2xx responses with an empty host take thePassThroughpath instead. Intentional and correct (URL rewriting is skipped either way), but not mentioned anywhere in the PR description. Worth a one-liner: "Non-processable 2xx with empty request host now PassThrough instead of Buffered — URL rewriting is skipped either way."
📝 note
- Content-Length fix in buffered path not mentioned:
publisher.rs:449— Phase 3 left the buffered path callingresponse.remove_header(CONTENT_LENGTH)after processing; this PR correctly sets it to the processed output length. The fix is right but it changes observable response headers and isn't mentioned in the description or commit messages.
👍 praise
once_cell→std::sync::LazyLockmigration (datadome.rs,google_tag_manager.rs,rsc.rs,shared.rs): Correct across all four sites, reduces the dependency surface, and allRegex::newexpect()messages follow the"should ..."convention.- 204 No Content exclusion: RFC 9110 §15.3.5 prohibits a message body on 204 — the guard placement before
take_body()and the dedicated test both make this correct and visible.
🌱 seedling
- No end-to-end test for the PassThrough path: The gate-logic tests are good but none of them call
handle_publisher_request()with a backend returning a binary content type. An integration-level test exercising the full path would catch regressions in the gate condition itself. Follow-up suggestion — does not block merge.
CI Status
- Integration tests: PASS
- fmt / clippy / cargo test: NOT RUN (CI gates only trigger on PRs targeting
main; expected for phased branching strategy)
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Review Summary
2 findings (1× P2, 1× P3) — both are suggestions for improvement, no blockers.
db597e6 to
ff05483
Compare
Non-processable 2xx responses (images, fonts, video) now stream directly to the client via PublisherResponse::PassThrough instead of buffering the entire body in memory. Content-Length is preserved since the body is unmodified.
Tests verify non-processable 2xx responses return PassThrough, non-processable errors stay Buffered, and processable content goes through Stream (not PassThrough).
Adds pass_through_preserves_body_and_content_length test that verifies io::copy produces identical output and Content-Length is preserved. Updates handle_publisher_request doc to describe all three response variants.
- Exclude 204 No Content from PassThrough (must not have body) - Remove Content-Length before streaming (stream_to_client uses chunked encoding, keeping both violates HTTP spec) - Add tests for 204 exclusion and empty-host interaction - Update doc comment and byte-level test to reflect CL removal
PassThrough reattaches the unmodified body and uses send_to_client() instead of stream_to_client() + io::copy. This preserves Content-Length (avoids chunked encoding overhead for images/fonts) and lets Fastly stream from its internal buffer without WASM memory buffering.
- Fix PassThrough doc comment operation order (set_body before finalize) - Update function doc to describe actual PassThrough flow (set_body + send_to_client, not io::copy) - Remove dead _enters_early_return variable, replace with comment
d9da0d9 to
fd33844
Compare
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Adds PublisherResponse::PassThrough for non-processable 2xx responses (images, fonts, video), streaming them directly to the client via send_to_client() instead of buffering in WASM memory. The implementation is correct: Content-Length is preserved, 204 No Content is properly excluded, and EC/consent headers are still applied. The 51% TTFB improvement is well-documented with real staging measurements.
Non-blocking
🤔 thinking
PassThroughdoc impliesfinalize_response()is the immediate caller's obligation — but inmain.rsit's called by the outerroute_requestpath, not thePassThrougharm itself. Current code is correct; the doc could mislead a future adapter implementor. (publisher.rs:277)
♻️ refactor
- Redundant
get_status()call —is_success: boolis captured at line 455, thenresponse.get_status()is called again at line 470 to producestatus. The innerstatus.is_success()duplicatesis_success. Hoistlet status = response.get_status()before the outerifand replacelet is_success = ...withlet is_success = status.is_success(). (publisher.rs:470)
📝 note
- No debug log when returning
PassThrough— the block logs"Skipping response processing..."at the top but has no log at thePassThroughreturn. Alog::debug!("Pass-through: binary response, Content-Type: {}, status: {}")would help with production tracing. (publisher.rs:473) - Gate tests verify boolean arithmetic, not
handle_publisher_request()directly — consistent with the existingstreaming_gate_*pattern and an accepted trade-off given the Fastly runtime can't be mocked. Noted for awareness. (publisher.rs:766)
⛏ nitpick
- 205 Reset Content not excluded — RFC 9110 §15.3.6 says 205 MUST NOT include message body content. Adding
status != StatusCode::RESET_CONTENTalongside the 204 guard would be spec-complete, low risk. (publisher.rs:471)
👍 praise
send_to_client()overstream_to_client()— correct call;stream_to_client()forces chunked encoding which would corruptContent-Lengthfor binary assets. (main.rs:247)- 204 No Content excluded correctly — HTTP spec prohibits a body in 204; tested and documented. (
publisher.rs:471) apply_ec_headerscalled before the PassThrough gate — EC/consent headers are correctly applied to pass-through responses, consistent with existing buffered behaviour.- Staging performance results — 51% TTFB, 28% FCP improvement with methodology documented. Exemplary.
CI Status
- browser integration tests: PASS
- integration tests: PASS
- prepare integration artifacts: PASS
| // browser knows the exact size for progress/layout. | ||
| // Exclude 204 No Content — it must not have a message body. | ||
| let status = response.get_status(); | ||
| if status.is_success() && status != StatusCode::NO_CONTENT && !should_process { |
There was a problem hiding this comment.
♻️ refactor — Redundant get_status() call
is_success: bool is captured at line 455 but response.get_status() is called again here. The inner status.is_success() re-checks what is_success already holds.
Suggested fix: hoist status before the outer if block:
let status = response.get_status();
let is_success = status.is_success();
if !should_process || request_host.is_empty() || !is_success {
// ...
if is_success && status != StatusCode::NO_CONTENT && !should_process {Eliminates the second get_status() call and makes the guard conditions read symmetrically.
| /// Parameters for `process_response_streaming`. | ||
| params: OwnedProcessResponseParams, | ||
| }, | ||
| /// Non-processable 2xx response (images, fonts, video). The caller must: |
There was a problem hiding this comment.
🤔 thinking — Doc implies finalize_response() is the immediate caller's obligation, but it happens implicitly in the outer path
The variant doc lists:
/// 2. Call `finalize_response()` on the response
In main.rs, the PassThrough arm only does response.set_body(body); Ok(response). finalize_response() is called by the general route_request control flow — not explicitly in the PassThrough branch. The current code is correct, but the doc reads as a per-arm obligation.
A future adapter (Axum, Cloudflare) implementing its own route_request could interpret this as calling finalize_response() in the match arm, then have it called again by the outer path. Consider updating to:
/// The adapter must call `response.set_body(body)` before sending.
/// `finalize_response()` and `send_to_client()` are the adapter's
/// responsibility at the outer response-dispatch level.
| // Exclude 204 No Content — it must not have a message body. | ||
| let status = response.get_status(); | ||
| if status.is_success() && status != StatusCode::NO_CONTENT && !should_process { | ||
| let body = response.take_body(); |
There was a problem hiding this comment.
⛏ nitpick — 205 Reset Content not excluded alongside 204
RFC 9110 §15.3.6: "A server MUST NOT generate content in a 205 response." If a misbehaving origin sends 205 with Content-Length, PassThrough would relay it. Low risk in practice, but spec-complete would be:
if is_success
&& status != StatusCode::NO_CONTENT // 204
&& status != StatusCode::RESET_CONTENT // 205 — RFC 9110 §15.3.6
&& !should_process
{| // Exclude 204 No Content — it must not have a message body. | ||
| let status = response.get_status(); | ||
| if status.is_success() && status != StatusCode::NO_CONTENT && !should_process { | ||
| let body = response.take_body(); |
There was a problem hiding this comment.
👍 praise — 204 No Content exclusion is correct and well-reasoned
HTTP spec prohibits a message body in 204 responses. Excluding it from PassThrough (which reattaches a body and calls send_to_client()) is correct. Tested and documented.
| if status.is_success() && status != StatusCode::NO_CONTENT && !should_process { | ||
| let body = response.take_body(); | ||
| return Ok(PublisherResponse::PassThrough { response, body }); | ||
| } |
There was a problem hiding this comment.
📝 note — No debug log before returning PassThrough
The block logs "Skipping response processing - should_process: ..., status: ..." at the top, but there's no log when actually returning PassThrough. Production tracing would benefit from:
log::debug!(
"Pass-through: binary response, Content-Type: {}, status: {}",
content_type, status
);
return Ok(PublisherResponse::PassThrough { response, body });| } | ||
|
|
||
| #[test] | ||
| fn pass_through_gate_streams_non_processable_2xx() { |
There was a problem hiding this comment.
📝 note — Gate tests verify boolean arithmetic, not handle_publisher_request() directly
This matches the existing streaming_gate_* pattern, so it's consistent. All five pass_through_gate_* tests verify the formula is_success && !should_process in isolation — a typo in the actual gate condition would pass them all undetected.
pass_through_preserves_body_and_content_length is more meaningful since it exercises real Fastly API behaviour. Given the runtime can't be mocked in unit tests, this is a known trade-off — not a blocker.
| // Fastly streams the body from its internal buffer — no WASM | ||
| // memory buffering occurs. | ||
| response.set_body(body); | ||
| Ok(response) |
There was a problem hiding this comment.
👍 praise — Correct choice of send_to_client() over stream_to_client()
stream_to_client() forces chunked Transfer-Encoding which would conflict with the origin's Content-Length header, causing browsers to see a malformed response for binary assets. send_to_client() lets Fastly relay the body from its internal buffer with the original headers intact. Clean and correct.
|
⛏ nitpick — PR title references The title reads: Phase 4: Stream binary pass-through responses via io::copy The first commit used Suggested title: Phase 4: Stream binary pass-through responses via send_to_client() |
Summary
Stream non-processable 2xx responses (images, fonts, video) directly to the client instead of buffering the entire body in memory. Uses
send_to_client()with the unmodified body to preserveContent-Lengthand avoid chunked encoding overhead.Closes #592, closes #593.
Part of epic #563. Depends on Phase 3 (#591).
Performance results (staging vs production, median over 8 runs, Chrome 1440x900)
Measured on getpurpose.ai via Chrome DevTools Protocol with
--host-resolver-rulesto route to staging Fastly edge.How it works
The streaming gate in
handle_publisher_requestnow has three outcomes:PassThroughreattaches the unmodified body and sends viasend_to_client(), preservingContent-Length. This avoids both WASM memory buffering and chunked encoding overhead.What changed
publisher.rsPassThroughvariant, gate logic, 204 exclusion, testsmain.rsPassThrough: reattach body, return forsend_to_client()Tests added
pass_through_gate_streams_non_processable_2xx— images/fonts return PassThroughpass_through_gate_buffers_non_processable_error— 4xx stays Bufferedpass_through_gate_does_not_apply_to_processable_content— HTML goes through Streampass_through_gate_excludes_204_no_content— 204 stays Buffered (HTTP spec)pass_through_gate_applies_with_empty_request_host— empty host still passes throughpass_through_preserves_body_and_content_length— byte-for-byte identity + CL preservedVerification
cargo test --workspace— 772 passed, 0 failedcargo clippy --workspace --all-targets --all-features -- -D warnings— cleancargo fmt --all -- --check— cleancargo build --release --target wasm32-wasip1— successTest plan