Skip to content

July release candidate (DO NOT MERGE)#919

Draft
ChristianPavilonis wants to merge 66 commits into
mainfrom
rc/july
Draft

July release candidate (DO NOT MERGE)#919
ChristianPavilonis wants to merge 66 commits into
mainfrom
rc/july

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Consolidates the July release-candidate changes for review and merge to main.
  • Includes auction transport stability, Fastly origin streaming, Prebid User ID diagnostics/EID preservation, APS OpenRTB, and server-side ad-stack improvements.

Included pull requests

Test plan

  • cargo fmt --all -- --check
  • cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin
  • cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm
  • cd crates/trusted-server-js/lib && npx vitest run

prk-Jr and others added 30 commits July 8, 2026 14:46
Fastly dynamic backend names embed the first-byte and between-bytes
timeouts so a registration can never be silently reused with a different
transport configuration. Deriving those timeouts from the remaining
wall-clock auction budget minted a new backend name on nearly every
request, defeating cross-request TCP/TLS connection reuse (Fastly pools
connections per backend name) and accumulating registrations toward the
per-service dynamic backend limit.

Compute the effective transport timeout from the configured provider
timeout verbatim when the budget allows, floor the budget-bound value to
250ms buckets otherwise, and pass sub-quantum remainders through exactly
so publishers with sub-250ms configured budgets keep launching. The
quantized value feeds both the backend name and the registered
configuration, so they cannot diverge. Rounding down never extends a
transport cap past the auction deadline, which the mediator and
dispatched-collect paths rely on to bound the </body> hold.

Also add a Fastly platform test pinning predict_name == ensure for the
same spec, since the orchestrator maps responses back to providers by
predicted backend name.

Fixes #847
Publisher pages were fully buffered before the first byte reached the
client: the platform client materialized the origin body (10 MiB cap),
the rewrite pipeline ran over an in-memory cursor, and the EdgeZero
finalize buffered the assembled response while awaiting auction
collection. TTFB therefore tracked full origin transfer plus the
auction instead of origin first byte.

- Add supports_streaming_responses() to PlatformHttpClient (default
  false, Fastly true) and request with_stream_response() on the
  publisher origin fetch only where honored
- Teach the pipeline to consume Body::Stream asynchronously:
  BodyChunkSource (cumulative raw-byte cap via
  publisher.max_buffered_body_bytes), push-style
  BodyStreamDecoder/BodyStreamEncoder in streaming_processor
- Replace the Fastly buffered finalize with
  publisher_response_into_streaming_response: a lazy Body::Stream that
  commits headers at origin first byte, streams rewritten chunks, and
  holds only the </body> tail for auction collection; bids still
  inject before body close
- Share one hold implementation (hold_step_decoded_chunk /
  hold_finish_segments) between the lazy body and the writer-driven
  loop so the paths cannot drift; collect_non_html_auction dedupes the
  collect-before-stream path
- Finalize brotli decode with close() so truncated origin streams
  error instead of silently truncating; decode failures emit
  stream_decode_error telemetry
- Guard bodiless (HEAD/204/304) responses and log wasted auction
  dispatch, matching the buffered finalizer

Local A/B on a 183 KB gzip publisher page with a live 3-slot auction
(release builds, 20 interleaved rounds): TTFB median 741 ms buffered
vs 161 ms streamed (-78%); guest wall time and wasm heap unchanged.
Address the deep-review findings on the streaming cutover:

- Cap cumulative decoded bytes in BodyStreamDecoder against
  publisher.max_buffered_body_bytes: the chunk source only bounds raw
  compressed bytes, so a decompression bomb could expand ~1000x past it
  and push unbounded decoded volume through the rewrite pipeline
- Detect truncated deflate streams: write::ZlibDecoder::try_finish
  accepts truncated input silently, so the deflate arm now drives
  flate2::Decompress directly and requires Status::StreamEnd at
  finalization; trailing bytes after the end marker stay ignored.
  Add truncated-gzip and truncated-deflate regression tests
- Make BodyChunkSource::next_chunk cancellation-safe by polling the
  body in place instead of moving it out across an await; a cancelled
  pull no longer turns into a silent EOF
- Log dispatched auctions dropped uncollected (client disconnect
  mid-stream or never-polled body) via DispatchedAuctionGuard; the
  guard is created before the lazy stream so unpolled drops log too
- Share the pull+decode step between the lazy publisher body and the
  write-sink drivers (hold_step_next_chunk / passthrough_step),
  removing the unreachable!() error plumbing and the triplicated
  processor selection; document body_close_hold_loop_stream as
  groundwork for the buffered adapters' streaming cutover
- Pass identity-encoded chunks through zero-copy and finish encoders
  by consuming them instead of allocating a throwaway replacement
- Add a Fastly dispatch test asserting the publisher fallback returns
  Body::Stream without a stale Content-Length, plus a comment on why
  the publisher fetch gates streaming on capability while the asset
  path does not

Behavior note: gzip bodies with trailing garbage after the trailer now
error mid-stream; the old read-path decoder ignored them.
The buffered finalizer abandons a dispatched auction with
processor_init_error telemetry when HTML processor construction fails;
the streaming finalizer dropped the in-flight SSP responses silently.
Make publisher_response_into_streaming_response async and emit the same
abandonment before returning the construction error.
Publisher-specific bundles need diagnostics based on the modules they
actually contain. Otherwise, missing identity integrations can be hidden
by the default preset.

Refresh the comparison when auctions begin so late publisher
configuration is visible without repeating warnings.

Resolves: #886
The server-side auction stream path only emitted a summary counter
(ssp/mediator/winning/time), so an operator seeing winning=0 could not
tell whether prebid returned nothing, errored, or bid below the floor.

Serialize the full provider_responses (and mediator_response) into the
ts-debug HTML comment so the SSAT surfaces the same prebid server
response detail available from the /auction endpoint. Bid creative and
metadata are attacker/partner-influenced, so neutralize the '-->' and
'--!>' comment terminators before embedding to keep the dump inside the
comment and out of the live DOM.
When Prebid Server returns a non-2xx status, the parser returned a bare
AuctionResponse::error with empty metadata — indistinguishable in the
ts-debug dump from a transport, parse, or timeout failure, all of which
tag error_type. An operator seeing status=error with metadata={} had no
way to know the upstream HTTP code without log access.

Attach error_type=http_status, the status code, and a 512-byte body
snippet to the error response metadata so the auction dump shows exactly
why prebid errored (e.g. a 4xx from a PBS rejecting the request).
Address PR review of the SSAT debug-dump change:

- The PBS non-2xx response body was attached to AuctionResponse.metadata,
  which ProviderSummary clones verbatim into ext.orchestrator.provider_details
  on the public /auction response — violating the documented invariant in
  auction/orchestrator.rs. Drop the body from metadata, keep only the numeric
  status, and log the snippet server-side at warn.
- Register ERROR_TYPE_HTTP_STATUS and match it in provider_status so PBS HTTP
  errors get their own telemetry bucket instead of the transport_error
  fallback.
- Bound the ts-debug dump: compact serialization capped at 256 KiB, and skip
  the mediator_response line when no mediator ran.
- Correct the auction_html_comment and prepend_auction_debug_comment docs to
  state the comment now embeds raw SSP creative markup (never enable in prod).
- Keep the targeted two-replace terminator neutralisation: a single
  replace("--", ...) re-forms -->/--!> at odd dash-run junctions and is not
  equivalent. Add a table-driven test over the comment-terminator vectors.
- Hoist test-local imports to module scope per CLAUDE.md.
…debug' into feat/ssat-write-prebid-response-debug
- Drop render_adm param: always include adm when creative present
- Gate GAM-bypass on per-bid debug_bid instead of a global window.tsjs flag
  (removes SPA-staleness edge case, TsjsApi change, and whole flag-emit task)
- Correct fallback scope: cache fallback only when adm absent; render failure
  after adm is supplied is not detectable
- Qualify sandbox claim: TS guarantees script-context escaping; bridge frame
  isolation depends on the Prebid Universal Creative
- Reconcile with existing ad_init.test.ts coverage (rename, no duplicates)
- Fix test assertion messages to expect("should ..."); list exact clippy gates
- Task 3: assert observable DOM (GAM iframe src) instead of spying on the
  module-private injectAdmIntoSlot
- Use existing make_bid helper + set .creative (make_test_bid_with_creative
  does not exist on this branch)
- Hostile-adm regression test covers both U+2028 and U+2029
- Add 'cd docs && npm run format' to verification
build_bid_map now always inserts the winning creative as adm so the pbRender
bridge can render it locally (no PBS Cache round trip); the verbose debug_bid
blob and the GAM-bypass gate stay behind inject_adm_for_testing. Rename the
param include_adm -> include_debug_bid and thread it through write_bids_to_state.
Reconcile the by-default test to the new behavior, drop the now-redundant
debug-only-adm test, and pin script-context escaping for a hostile adm
(</script> + U+2028/U+2029).
The direct GAM-replace path (injectAdmIntoSlot) now fires only when the bid
carries debug_bid, which is present only under inject_adm_for_testing. In
production the always-present adm is rendered by the pbRender bridge and GAM
stays in the loop. Add observable-DOM tests (GAM iframe src unchanged without
debug_bid, rewritten with it), strengthen the bridge test to prove inline adm
is preferred even when cache coords are present, and rename debug-adm
terminology to inline adm.
Resolve five correctness and resource-safety findings from the PR #867
review of the end-to-end Fastly publisher streaming path.

- Drive the deflate decoder to StreamEnd at finalization so a valid
  stream that exactly fills the internal output buffer is no longer
  rejected as truncated; the inflater is also drained after all input is
  consumed within a chunk.
- Decode concatenated (multi-member) gzip bodies via MultiGzDecoder on
  both the streaming decoder and the buffered read pipeline so adapters
  agree.
- Enforce the decoded-body cap during decompression through a bounded
  sink shared by the gzip and brotli codecs, so a compression bomb errors
  before its expanded bytes are buffered instead of after a full chunk
  expands; the deflate codec charges each produced block as it is emitted.
- Drop the body of bodiless responses (HEAD, 204, 205, 304) in both the
  streaming and buffered finalizer Buffered arms, and add RESET_CONTENT
  to response_carries_body, so a buffered-unmodified stream body is never
  streamed to the client for a response that must be bodiless.
- Keep the dispatched-auction guard armed across the collection await and
  disarm it only once collection reaches a terminal result, so a body
  dropped while collection is pending still logs the discarded SSP work.

Add regression tests for the deflate output-buffer boundary, multi-member
gzip, bodiless buffered stream bodies, and the auction guard sentinel.
Resolve the PR review by making transport-timeout canonicalization a
platform capability and hardening auction backend-name correlation.

- Move quantization behind PlatformBackend::canonicalize_transport_timeout_ms.
  Fastly floors budget-derived timeouts to a 250ms quantum with a bounded
  sub-quantum ladder [200,150,100,50]; other adapters use the exact remaining
  budget so bidder deadlines (Prebid tmax, APS timeout) are not shortened
  where no connection-pooling benefit exists.
- Bound sub-quantum backend-name cardinality: exact 1-249ms values no longer
  pass through, capping the budget-derived names a single origin can mint
  toward the per-service dynamic backend limit.
- Add a provider discriminator to PlatformBackendSpec, folded into every
  adapter's backend name, so two providers sharing one origin no longer
  collide on the response-correlation key. Reject a duplicate
  backend_to_provider insertion with an attributed launch failure instead of
  silently overwriting and misattributing a response.
- Make the orchestrator call-site tests deterministic: record predicted and
  registered transport timeouts separately and assert exact equality via a
  controllable platform backend, and enumerate the sub-quantum ladder to
  assert a bounded name cardinality.
- Correct the timeout-semantics comments that overstated absolute-deadline
  enforcement; the Fastly connect/first-byte/between-bytes timeouts bound
  connection, first-byte, and inactivity, not total response time. A true
  absolute deadline carried through the platform HTTP API remains follow-up
  work (#849).
Prebid non-2xx responses were reduced to bare provider errors, making intermittent failures difficult to diagnose. Surface safe HTTP metadata and bounded debug details while correlating server logs with the auction ID.
A configured bidder with no inline params and no matching override
expanded to `"bidder": {}`, which PBS rejects. After applying overrides,
drop fabricated empty bidders, preserve an explicitly supplied empty
object so genuine misconfiguration stays visible, and fall back to the
stored-request path when no eligible bidders remain.
ChristianPavilonis and others added 16 commits July 15, 2026 21:25
Resolve conflicts in favor of the streaming pipeline:
- app.rs: keep the lazy publisher_response_into_streaming_response
  import; Fastly streams, so buffer_publisher_response_async is unused
  here.
- publisher.rs: keep module-level flate2/futures imports and the
  body.is_stream() streaming branch in stream_html_with_auction_hold.

Main moved core to edition 2024 via workspace inheritance, enabling
let-chains and tripping clippy::collapsible_if; collapse the nested if
in passthrough_finish_segments to satisfy it.
BodyStreamEncoder::encode_chunk wrote each processed chunk into the
gzip/deflate/brotli codec and drained the inner buffer, but never
flushed the codec. Compressors buffer internally: write_all alone left
gzip emitting at most its 10-byte header and deflate/brotli emitting
nothing until finish(). Fastly commits the response headers early, so
the browser saw an open stream with no decodable HTML until the entire
origin transfer completed — the FCP regression tracked in #849.

Sync-flush the flate2 encoders (Flush::Sync — byte-aligned, no trailer)
and emit a brotli flush marker (BROTLI_OPERATION_FLUSH) after each
write. finish() still writes the terminating trailer, so the full
stream stays valid. Add a regression test that decodes the first chunk
with a flushed-but-unfinished decoder for every codec.
The close-body hold step processed a chunk into ready segments and the
held closing tail together, but awaited collect_stream_auction() before
returning any of them. When </body> landed in the first source chunk
(common for small pages), the client received no HTML until the auction
finished; for larger pages the whole prefix from the close-containing
chunk was delayed — contradicting the contract that only the </body>
tail is held while the auction rides alongside transfer.

Split hold_step_decoded_chunk so it returns the ready prefix plus a
close_found flag without collecting, and move collection plus held-tail
processing into a new hold_collect_close_tail. Both the lazy Fastly
stream and the write-sink driver now emit the ready prefix first, then
await collection, then emit the tail. Add a regression test asserting
the prefix is ready with close_found set while the auction is still
uncollected (ad_bids_state stays None until the tail step).
Both publisher finalizers dropped the body for bodiless responses but
kept the origin Content-Length verbatim, including a possible nonzero
value. RFC 9110 §8.6 forbids Content-Length on 204, and a nonzero length
on a now-empty 205 (§15.4.6) is invalid; inconsistent framing risks
interoperability failures and response-splitting across intermediaries.

Add make_response_bodiless: it empties the body and removes
Content-Length for 204, normalizes it to 0 for 205, and preserves it for
HEAD and 304 (which legitimately advertise the GET representation
length). Call it from the buffered-unmodified arm of both
buffer_publisher_response_async and publisher_response_into_streaming_response.

Split the combined bodiless regression test into per-status expectations
and add the matching coverage for the buffered finalizer.
A conditional navigation can dispatch a server-side auction and receive
a processable HTML 304 that classification routes to Stream. Both
finalizers' bodiless branches logged a warning and returned, dropping
params.dispatched_auction without emit_abandoned_auction — so the SSP
work and quota consumption had no terminal auction event, leaving a gap
in auction observability on a legitimate conditional-request path.

Take the dispatched auction in the bodiless branch of both
buffer_publisher_response_async and publisher_response_into_streaming_response
and await emit_abandoned_auction(..., "bodiless_response") with the
retained observation, matching the processor-init-error path. Add a
recording-telemetry-sink test (via a new test_support helper) asserting
both finalizers emit the bodiless_response abandonment for a 304 with a
dispatched auction.
The merge moved core to edition 2024, whose rustfmt sorts use-imports
case-sensitively (lowercase items last) and rewraps long lines. Reformat
the conflict-resolved imports and the new test assertions to match; no
behavior change.
Address the auction transport-timeout review findings:

- Derive dynamic backend names from a bounded readable prefix plus a
  SHA-256 digest of the complete backend spec, so distinct specs never
  alias to one name. Name equality now implies spec equality, making
  NameInUse reuse provably safe, and the name is bounded to Fastly's
  255-char limit regardless of host or discriminator length.
- Bound budget-derived transport-timeout cardinality with a globally
  finite ladder: keep the 250ms quantum through 2000ms, then snap larger
  budgets to a small fixed set of coarse rungs so a large configured
  ceiling can no longer mint hundreds of dynamic backends.
- Reject duplicate configured providers at startup, and check the
  predicted backend name before request_bids fires the outbound send,
  retaining the post-launch check as defense against a provider that
  resolves to an unexpected name.
…sport-timeouts

# Conflicts:
#	crates/trusted-server-adapter-fastly/src/backend.rs
#	crates/trusted-server-core/src/auction/orchestrator.rs
The backend name now carries a SHA-256 digest suffix, so the three
platform predict_name tests that pinned the exact/ending name string no
longer hold. Assert the readable body via starts_with/contains instead.
These were missed earlier because the local run filtered the platform
suite too narrowly; CI aborted on the first panic.
@aram356 aram356 changed the title July release candidate July release candidate (DO NOT MERGE) Jul 16, 2026
ChristianPavilonis and others added 5 commits July 16, 2026 10:54
Mint a trace tuple (auction_id, slot_id, bidder, bid/creative IDs,
adm_hash) server-side and carry it end-to-end on both render paths so a
creative in the DOM can be joined back to the winning bid in server
logs.

Server: Bid gains crid and bid_id (extracted from PBS, restored through
the mock mediator); adm_trace_hash() computes a 16-hex SHA-256 prefix of
the creative markup; build_bid_map emits hb_auction_id/hb_crid/
hb_adm_hash for SSAT and SPA page-bids; /auction responses carry
bid.ext.ts = {auction_id, adm_hash} and pass through upstream crid and
bid id with the legacy synthetic fallback; the orchestrator logs one
"auction winner:" line per winning bid on both the sync and dispatched
collect paths.

Client: new core/trace.ts writes a per-slot render registry to
window.tsjs.renders, stamps slot elements and iframes with data-ts-*
attributes (removing stale ones on re-render), and fires a
tsjs:adRendered CustomEvent. renderCreativeInline, the GPT
slotRenderEnded handler, and the pbRender bridge all record and stamp;
the bridge captures the slot element at message-receipt time so an SPA
navigation during a PBS Cache fetch cannot stamp the new route's slot
with stale data.
# Conflicts:
#	crates/trusted-server-js/lib/src/integrations/prebid/index.ts
#	crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts
@aram356
aram356 marked this pull request as draft July 17, 2026 15:44
…tives

# Conflicts:
#	crates/trusted-server-core/src/auction/formats.rs
#	crates/trusted-server-core/src/auction/orchestrator.rs
#	crates/trusted-server-core/src/auction/telemetry.rs
#	crates/trusted-server-core/src/auction/types.rs
#	crates/trusted-server-core/src/integrations/adserver_mock.rs
#	crates/trusted-server-core/src/integrations/aps.rs
#	crates/trusted-server-core/src/integrations/prebid.rs
#	crates/trusted-server-core/src/publisher.rs
#	crates/trusted-server-js/lib/src/core/auction.ts
#	crates/trusted-server-js/lib/src/core/request.ts
#	crates/trusted-server-js/lib/src/core/types.ts
#	crates/trusted-server-js/lib/src/integrations/gpt/index.ts
# Conflicts:
#	CHANGELOG.md
#	crates/trusted-server-core/src/auction/formats.rs
#	crates/trusted-server-core/src/auction/orchestrator.rs
#	crates/trusted-server-core/src/integrations/aps.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants